문서로 건너뛰기
Python

프레임워크

Django, Flask, FastAPI, 웹훅 엔드포인트, 그리고 절대 두 번 보내지 않는 백그라운드 작업.

Django

키는 환경 변수에서 읽어 settings에 두고, 뷰가 import하는 별도의 모듈에서 OpenEmail을 하나 만드십시오. 클라이언트는 여러 스레드가 안전하게 공유할 수 있으므로, 인스턴스 하나가 모든 요청을 처리하며 프로세스당 연결 풀 하나를 유지합니다. 모듈 이름은 openemail.py만 아니면 됩니다. 그 이름은 패키지를 가릴 수 있습니다.

settings.py
import os OPENEMAIL_API_KEY = os.environ['OPENEMAIL_API_KEY']OPENEMAIL_TIMEOUT = 10.0OPENEMAIL_SENDER = 'Acme <[email protected]>'
mailer.py
from django.conf import settingsfrom openemail import OpenEmail mailer = OpenEmail(settings.OPENEMAIL_API_KEY, timeout=settings.OPENEMAIL_TIMEOUT)
views.py
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 클라이언트도 여기서 쓸 수 있습니다. 앱 설정의 ready() 메서드에서 init(settings.OPENEMAIL_API_KEY)를 한 번 호출하고, 발송하는 곳마다 openemail을 import하면 됩니다.

Flask

앱 팩토리가 앱과 함께 클라이언트를 만들어 app.extensions에 보관하고, 뷰는 current_app을 통해 접근합니다. 클라이언트도 받을 수 있는 팩토리로 만들면, 테스트에서 httpx.MockTransport로 만든 클라이언트를 넘길 수 있습니다.

app.py
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 app

FastAPI

lifespan 안에서 AsyncOpenEmail을 하나 만드십시오. 그러면 클라이언트가 요청을 처리하는 이벤트 루프에 속하고 서버가 멈출 때 닫힙니다. 라우트에는 의존성으로 넘기십시오.

main.py
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]을 통해 클라이언트를 바꿔 끼우므로, 어떤 라우트도 API에 닿지 않습니다.

웹훅 엔드포인트

모든 전달은 처리하기 전에 원본 본문과 요청 헤더로 검증하십시오. FastAPI에서는 await request.body()와 request.headers, Django에서는 request.body와 request.headers, Flask에서는 request.get_data()와 request.headers입니다. 헤더 조회는 대소문자를 구분하지 않으므로, 각 프레임워크 고유의 헤더 객체를 그대로 쓸 수 있습니다.

webhooks.py
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)
webhook_views.py
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는 CSRF 토큰이 없는 POST를 거부하는데 전달에는 CSRF 토큰이 없으므로, 뷰를 csrf_exempt로 만듭니다. 요청이 OpenEmail에서 왔음을 증명하는 것은 서명입니다. request.META는 키 이름이 HTTP_X_OPENEMAIL_SIGNATURE 형태로 바뀌어 있으므로, 그 대신 request.headers를 읽으십시오.

2xx로 빠르게 응답하고 작업은 그 뒤에 하십시오. 응답이 없거나 408, 425, 429, 5xx를 받은 전달은 약 27시간 30분 동안 최대 8번까지 다시 시도되며, 재전송은 같은 id로 이벤트를 다시 보내므로 처리한 id를 보관해 두고 반복된 것은 건너뛰십시오.

백그라운드 작업

작업 큐는 실패한 태스크를 다시 실행하는데, 태스크는 이메일이 나간 뒤에 실패할 수도 있습니다. 응답을 잃었거나, 워커가 끝나기 전에 멈춘 경우입니다. 발송이 필요해진 원인에서 만든 idempotency_key=를 넘기십시오. 그러면 태스크의 모든 실행이 같은 키를 담으므로, 반복된 실행은 두 번째 메시지를 보내지 않고 원래 메시지를 재생합니다.

tasks.py
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는 태스크가 실행을 마친 뒤에야 확인 응답을 보냅니다. 그래서 워커가 멈춰 중단된 태스크가 다시 전달될 수 있는데, 키가 그 두 번째 실행을 재생으로 바꾸므로 여기서는 안전합니다. RQ의 Retry도 실패한 작업을 다시 실행하며, 효과는 같습니다.

jobs.py
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로 만드십시오. 본문도 마찬가지로 태스크의 인자만으로 만드십시오. 같은 키에 다른 본문으로 반복하면 재생되지 않고 422 idempotency_key_reuse로 거부됩니다.

클라이언트는 모듈 수준에서 만드십시오. 첫 요청 전까지는 연결을 열지 않으므로, 부모에서 fork된 각 워커 프로세스가 각자 자신의 연결을 엽니다.