Cuando una API responde 202 Accepted, casi siempre sentimos alivio. El request no fallo, el worker ya tomara el trabajo, y todo parece limpio. El problema es que para email async esa respuesta dice muy poco. No te cuenta si el mensaje quedo en cola, si ya salio del provider o si se perdio entre reintentos. En FastAPI, ese hueco se nota bastante rapido.
Un patrón que me gusta en estos casos es devolver un recibo de entrega desde la API. No reemplaza al worker ni a la cola. Solo crea una referencia estable para seguir el estado del correo sin abrir logs a lo loco. Parece un detalle menor, pero hace que soporte, QA y backend hablen del mismo envio, no de suposiciones distintas.
Por qué un 202 no alcanza para correos async
El error comun es tratar "aceptado" como si significara "enviado". En realidad solo significa que el backend aceptó el trabajo. Entre ese punto y la entrega real pasan varias cosas:
- el job entra a cola
- el worker arma la plantilla
- el proveedor responde con exito, retraso o fallo
- el sistema actualiza el estado final
Si tu API no devuelve un identificador consultable, terminas resolviendo dudas con logs dispersos o con un dashboard del proveedor. Eso sirve aveces, pero llega tarde y deja a otros equipos medio ciegos. Tambien complica pruebas con una direccion de correo temporal, porque sabes que el request corrio pero no cual intento genero el mensaje correcto.
Este tipo de visibilidad se parece bastante a tener contexto verificable en correos de cambio. La idea de fondo es igual: cada accion importante necesita una referencia clara y compartida.
El recibo de entrega que devuelve la API
Yo suelo pensar el recibo como un recurso pequeño:
receipt_idstatusemail_typecreated_atlast_error
Con eso, el cliente puede mostrar "procesando", reintentar con criterio o consultar un endpoint de estado. No hace falta que el frontend conozca la cola ni la herramienta de envío. Solo necesita un contrato pequeño y estable.
Algo simple funciona bien:
- El
POST /emails/reset-passwordcrea el recibo en estadoqueued. - La respuesta devuelve
receipt_id. - El worker actualiza el estado a
sending,sentofailed. - El cliente consulta
GET /email-receipts/{receipt_id}por unos segundos.
Es una idea parecida a los contratos de salida para agentes cron: primero fijas el artefacto que todos van a leer, despues optimizas el resto. Cuando haces eso, hasta bugs chicos se vuelven bastante mas faciles de ubicar.
Ejemplo pequeño con FastAPI
from uuid import uuid4
from fastapi import APIRouter, BackgroundTasks
from pydantic import BaseModel
router = APIRouter()
class PasswordResetIn(BaseModel):
user_id: str
email: str
@router.post("/emails/reset-password", status_code=202)
async def queue_reset_email(payload: PasswordResetIn, bg: BackgroundTasks):
receipt_id = f"mail_{uuid4().hex}"
await save_receipt(
receipt_id=receipt_id,
user_id=payload.user_id,
email_type="password_reset",
status="queued",
)
bg.add_task(send_reset_email, payload.email, receipt_id)
return {"receipt_id": receipt_id, "status": "queued"}
@router.get("/email-receipts/{receipt_id}")
async def get_email_receipt(receipt_id: str):
return await load_receipt(receipt_id)
Y el worker:
async def send_reset_email(email: str, receipt_id: str) -> None:
await update_receipt(receipt_id, status="sending")
try:
await provider.send(
to=email,
template="password_reset",
metadata={"receipt_id": receipt_id},
)
await update_receipt(receipt_id, status="sent")
except Exception as exc:
await update_receipt(receipt_id, status="failed", last_error=str(exc))
No es un sistema perfecto, pero si crea una frontera util. El cliente deja de asumir demasiado, y el backend deja una pista concreta para depurar. En APIs pequeñas esto ayuda mucho, por que evita meter observabilidad enorme antes de tener una necesidad real.
Cómo validar el flujo sin leer logs a ciegas
Para pruebas, yo revisaria tres cosas:
- El
POSTsiempre devuelve unreceipt_id. - El
GETcambia dequeuedasentofailed. - El inbox de prueba contiene metadata o correlacion suficiente.
Si usas una inbox de staging o un tem email para flujos efimeros, este patrón te da una ruta mejor que "esperar y buscar el ultimo correo". Tambien reduce falsos positivos cuando dos pruebas corren casi al mismo tiempo. Y si el provider va lento, el estado intermedio deja claro que no fue un bug del cliente, solo una entrega atrasada.
Un detalle que vale oro: guarda last_error de forma legible. No con un traceback gigante, sino con un mensaje corto y util. Cuando soporte pregunta "que paso con este email?", poder responder desde el recibo ahorra mucho ida y vuelta, la verdad.
Segun la State of Application Strategy Report 2024 de F5, el 96% de las organizaciones ya operan aplicaciones modernas en varios entornos. Ese dato no habla solo de email, claro, pero si explica por qué conviene tener contratos simples entre clientes, workers y servicios auxiliares: el sistema ya no vive en un solo lugar y se rompe raro si cada parte interpreta estados distintos.
Q&A
¿Esto no duplica información del proveedor?
Un poco, pero con intención. El proveedor sabe si aceptó el envío. Tu API necesita saber qué prometió al cliente y cómo exponer ese estado de forma estable.
¿Conviene usar polling siempre?
Para muchos paneles internos, sí. Un polling corto de 5 a 15 segundos suele bastar. Si luego necesitas websockets o webhooks, ya partes de un contrato sano.
¿Qué cambia en la práctica?
Cambia la conversacion operativa. En vez de "creo que el correo salió", pasas a "el recibo mail_x quedo en failed por esta razon". Suena pequeño, pero ordena bastante el trabajo diario, y evita varios malentendidos tontos.
Top comments (0)