فریمورکها
Django، Flask و FastAPI، یک اندپوینت وبهوک، و jobهای پسزمینهای که هرگز دو بار نمیفرستند.
Django
کلید را در settings نگه دارید، خواندهشده از محیط، و یک OpenEmail را در ماژولی جداگانه بسازید که viewها آن را import میکنند. کلاینت را میتوان بیخطر میان threadها به اشتراک گذاشت، پس یک نمونه به همهٔ درخواستها پاسخ میدهد و یک استخر اتصال برای پردازه نگه میدارد. ماژول را هر چیزی جز openemail.py نام بگذارید، چون این نام میتواند پکیج را پنهان کند.
import os OPENEMAIL_API_KEY = os.environ['OPENEMAIL_API_KEY']OPENEMAIL_TIMEOUT = 10.0OPENEMAIL_SENDER = 'Acme <[email protected]>'from django.conf import settingsfrom openemail import OpenEmail mailer = OpenEmail(settings.OPENEMAIL_API_KEY, timeout=settings.OPENEMAIL_TIMEOUT)from django.conf import settingsfrom django.http import HttpRequest, JsonResponsefrom django.views.decorators.http import require_POSTfrom openemail import OpenEmailApiError from acme.mailer import mailer @require_POSTdef invite(request: HttpRequest) -> JsonResponse: try: sent = mailer.emails.send({ 'from': settings.OPENEMAIL_SENDER, 'to': request.POST['email'], 'subject': 'You are invited to Acme', 'text': 'Accept the invitation to join the workspace.', }) except OpenEmailApiError as error: if error.is_validation: return JsonResponse({'error': error.message, 'field': error.param}, status=422) raise return JsonResponse({'id': sent['id']}, status=202)کلاینت آمادهٔ openemail اینجا هم کار میکند: init(settings.OPENEMAIL_API_KEY) را یک بار، از متد ready() در app config برنامهتان، فراخوانی کنید و هر جا که ارسال میکنید openemail را import کنید.
Flask
app factory کلاینت را همراه با برنامه میسازد و آن را در app.extensions نگه میدارد، و viewها از راه current_app به آن میرسند. اگر factory کلاینت را هم بپذیرد، آزمون میتواند کلاینتی ساختهشده روی httpx.MockTransport به آن بدهد.
import os from flask import Flask, current_app, requestfrom openemail import OpenEmail def create_app(mailer: OpenEmail | None = None) -> Flask: app = Flask(__name__) app.extensions['openemail'] = mailer or OpenEmail(os.environ['OPENEMAIL_API_KEY'], timeout=10) @app.post('/invites') def invite() -> tuple[dict[str, str], int]: client: OpenEmail = current_app.extensions['openemail'] sent = client.emails.send({ 'from': 'Acme <[email protected]>', 'to': request.form['email'], 'subject': 'You are invited to Acme', 'text': 'Accept the invitation to join the workspace.', }) return {'id': sent['id']}, 202 return appFastAPI
یک AsyncOpenEmail را در lifespan بسازید، تا به حلقهٔ رویدادی تعلق داشته باشد که به درخواستها پاسخ میدهد و وقتی سرور میایستد بسته شود، و آن را با یک dependency به routeها بدهید.
from collections.abc import AsyncIteratorfrom contextlib import asynccontextmanagerfrom typing import Annotated from fastapi import Body, Depends, FastAPI, Requestfrom openemail import AsyncOpenEmail @asynccontextmanagerasync def lifespan(app: FastAPI) -> AsyncIterator[None]: async with AsyncOpenEmail(timeout=10) as mailer: app.state.openemail = mailer yield app = FastAPI(lifespan=lifespan) def get_openemail(request: Request) -> AsyncOpenEmail: mailer: AsyncOpenEmail = request.app.state.openemail return mailer Mailer = Annotated[AsyncOpenEmail, Depends(get_openemail)] @app.post('/invites', status_code=202)async def invite(email: Annotated[str, Body(embed=True)], mailer: Mailer) -> dict[str, str]: sent = await mailer.emails.send({ 'from': 'Acme <[email protected]>', 'to': email, 'subject': 'You are invited to Acme', 'text': 'Accept the invitation to join the workspace.', }) return {'id': sent['id'], 'status': sent['status']}آزمون کلاینت را از راه app.dependency_overrides[get_openemail] جایگزین میکند، پس هیچ routeی به API نمیرسد.
نقطههای پایانی وبهوک
هر تحویل را پیش از اقدام بر پایهٔ آن، با بدنهٔ خام و سرآیندهای درخواست تأیید کنید: await request.body() و request.headers در FastAPI، request.body و request.headers در Django، و request.get_data() و request.headers در Flask. جستوجوی سرآیند به بزرگی و کوچکی حروف حساس نیست، پس شیء سرآیندهای خودِ هر فریمورک همانطور که هست کار میکند.
import os from fastapi import FastAPI, HTTPException, Request, Responsefrom openemail import WEBHOOK_EVENTS, WebhookVerificationError, verify_webhook_signature from acme.jobs import handle_bounce app = FastAPI() @app.post('/webhooks/openemail', status_code=204)async def openemail_webhook(request: Request) -> Response: try: event = verify_webhook_signature( payload=await request.body(), headers=request.headers, secret=os.environ['OPENEMAIL_WEBHOOK_SECRET'], ) except WebhookVerificationError as error: raise HTTPException(status_code=400, detail='bad signature') from error if event['type'] == WEBHOOK_EVENTS.EMAIL_BOUNCED: handle_bounce.delay(event['id'], event['data']) return Response(status_code=204)import os from django.http import HttpRequest, HttpResponsefrom django.views.decorators.csrf import csrf_exemptfrom django.views.decorators.http import require_POSTfrom openemail import WebhookVerificationError, verify_webhook_signature from acme.models import ReceivedEvent @csrf_exempt@require_POSTdef openemail_webhook(request: HttpRequest) -> HttpResponse: try: event = verify_webhook_signature( payload=request.body, headers=request.headers, secret=os.environ['OPENEMAIL_WEBHOOK_SECRET'], ) except WebhookVerificationError: return HttpResponse('bad signature', status=400) ReceivedEvent.objects.get_or_create( id=event['id'], defaults={'type': event['type'], 'data': event['data']}, ) return HttpResponse(status=204)Django درخواست POSTی را که توکن CSRF نداشته باشد رد میکند، و یک تحویل چنین توکنی ندارد، پس view به شکل csrf_exempt است: امضا همان چیزی است که ثابت میکند درخواست از OpenEmail آمده است. بهجای request.META، که کلیدهایش به شکل HTTP_X_OPENEMAIL_SIGNATURE تغییر نام میدهند، request.headers را بخوانید.
سریع با یک 2xx پاسخ دهید و کار را پس از آن انجام دهید. تحویلی که پاسخی نگیرد، یا 408، 425، 429 یا 5xx بگیرد، دوباره تلاش میشود، تا 8 بار در حدود 27 ساعت و نیم، و ارسال دوباره یک رویداد را با همان id دوباره میفرستد، پس شناسههایی را که پردازش کردهاید نگه دارید و از تکرار بگذرید.
jobهای پسزمینه
صف jobها وقتی یک task شکست بخورد دوباره اجرایش میکند، و یک task ممکن است پس از رفتن ایمیلش شکست بخورد: پاسخ گم شده، یا worker پیش از تمام کردن کار متوقف شده است. یک idempotency_key= بدهید که از همان چیزی گرفته شده باشد که ارسال را لازم کرد. آنوقت هر اجرای task همان کلید را دارد، پس تکرار، پیام اصلی را بازپخش میکند بهجای آنکه پیام دومی بفرستد.
from celery import Task, shared_taskfrom openemail import OpenEmail, OpenEmailApiError, OpenEmailNetworkError mailer = OpenEmail() @shared_task(bind=True, acks_late=True, max_retries=5)def send_receipt(self: Task, order_id: str, email: str) -> str: try: sent = mailer.emails.send( { 'from': 'Acme Billing <[email protected]>', 'to': email, 'subject': f'Receipt for order {order_id}', 'text': f'Thank you for order {order_id}.', }, idempotency_key=f'receipt:{order_id}', ) except OpenEmailNetworkError as error: raise self.retry(exc=error, countdown=30) except OpenEmailApiError as error: if error.is_server_error: raise self.retry(exc=error, countdown=30) raise return sent['id']با acks_late=True، Celery یک task را فقط پس از اجرا شدنش تأیید میکند، پس taskی که با متوقف شدن یک worker نیمهکاره مانده میتواند دوباره تحویل داده شود، و این اینجا بیخطر است چون کلید آن اجرای دوم را به یک بازپخش تبدیل میکند. Retry در RQ یک job شکستخورده را دوباره اجرا میکند، با همین نتیجه.
from redis import Redisfrom rq import Queue, Retry from openemail import OpenEmail mailer = OpenEmail() def send_receipt(order_id: str, email: str) -> str: sent = mailer.emails.send( { 'from': 'Acme Billing <[email protected]>', 'to': email, 'subject': f'Receipt for order {order_id}', 'text': f'Thank you for order {order_id}.', }, idempotency_key=f'receipt:{order_id}', ) return sent['id'] queue = Queue(connection=Redis())queue.enqueue(send_receipt, 'AC-4192', '[email protected]', retry=Retry(max=5, interval=[10, 60, 300]))کلید را از همان چیزی بگیرید که ارسال را لازم کرد، هرگز از ساعت. کلید 1 تا 255 نویسه از حروف، ارقام، زیرخط، نقطه، دونقطه یا خط تیره است، پس آن را از یک id بسازید نه از یک نشانی ایمیل. بدنه را هم فقط از آرگومانهای task بسازید: تکراری با همان کلید و بدنهٔ متفاوت، بهجای بازپخش، با 422 idempotency_key_reuse رد میشود.
کلاینت را در سطح ماژول بسازید. تا نخستین درخواستش هیچ اتصالی باز نمیکند، پس هر پردازهٔ worker که از پردازهٔ والد fork شود اتصالهای خودش را باز میکند.