Cuando una app en FastAPI manda emails desde una cola, el fallo casi nunca está en send_email(). El problema suele ser que no sabes qué pasó entre el request original, el worker, el reintento y la confirmación final. En equipos pequeños eso se nota tarde, y en equipos grandes se vuelve un dolor diario.
En varios backends he visto el mismo patrón: la cola "funciona", pero investigar un email perdido toma demasiado tiempo. Si quieres depurar rapido, necesitas trazabilidad suficiente desde el inicio, no más prints sueltos al final.
Por que una cola de email se vuelve opaca tan rapido
Una tarea de email normalmente cruza varias capas:
- endpoint HTTP
- validación de payload
- enqueue del job
- worker async
- proveedor externo
- actualización de estado
Si cada capa escribe logs distintos, sin una clave común, buscar el origen del problema se vuelve bastante lento. Realesmente no hace falta una plataforma enorme para arreglarlo; hace falta disciplina en los eventos y nombres consistentes.
También conviene diferenciar dos preguntas:
- ¿el job fue aceptado?
- ¿el email fue entregado o falló?
Mezclar esas dos respuestas en un solo log complica mucho el soporte. Este enfoque se parece a cómo se diseñan los contratos de inbox para automatización: primero dejas claro qué evento ocurrió, luego validas la siguiente transición.
Que guardar en cada evento del worker
Mi regla simple es: cada intento debe poder entenderse sin abrir cinco dashboards. Para eso, cada evento de log debería incluir como mínimo:
job_idrequest_id-
user_ido un identificador seguro del destinatario template_nameattemptproviderstatusduration_ms
Si usas un correlation_id estable desde FastAPI hasta el worker, ya ganaste bastante. No resuelve todo, pero reduce mucho el tiempo de diagnóstico.
Tambien vale la pena registrar el motivo de reintento con una categoría corta. Algo como timeout, provider_5xx o invalid_recipient es más útil que una excepción gigante pegada en una sola línea. La excepción completa puede vivir aparte.
Un detalle que evita bugs tontos: normaliza entradas dudosas antes de encolar. He visto datos de prueba como temp gamil com o tempail mail colarse en staging y contaminar métricas. No es grave por si mismo, pero ensucia el analisis si nadie marca que era un dato artificial.
Un patron simple en FastAPI para seguir cada intento
Un patrón muy usable es separar el evento de negocio del evento de entrega. El endpoint solo registra que se pidió enviar un email. El worker registra cada intento real.
from fastapi import FastAPI, BackgroundTasks
from pydantic import BaseModel
from uuid import uuid4
import time
import logging
app = FastAPI()
log = logging.getLogger("email_jobs")
class EmailRequest(BaseModel):
to: str
template: str
def send_email_job(job_id: str, request_id: str, to: str, template: str) -> None:
started = time.perf_counter()
attempt = 1
log.info(
"email_attempt_started",
extra={
"job_id": job_id,
"request_id": request_id,
"template": template,
"attempt": attempt,
"status": "started",
},
)
# aqui llamarias a tu proveedor
log.info(
"email_attempt_finished",
extra={
"job_id": job_id,
"request_id": request_id,
"template": template,
"attempt": attempt,
"status": "sent",
"duration_ms": round((time.perf_counter() - started) * 1000, 2),
},
)
@app.post("/emails")
def queue_email(payload: EmailRequest, bg: BackgroundTasks):
job_id = str(uuid4())
request_id = str(uuid4())
log.info(
"email_job_queued",
extra={
"job_id": job_id,
"request_id": request_id,
"template": payload.template,
"status": "queued",
},
)
bg.add_task(send_email_job, job_id, request_id, payload.to, payload.template)
return {"job_id": job_id, "request_id": request_id}
No es una arquitectura exotica, pero funciona muy bien para equipos que todavía no quieren meter otra capa compleja. Si luego migras a Celery, RQ o Dramatiq, la idea base sigue siendo la misma: un identificador por solicitud y eventos cortos, comparables, y faciles de buscar.
Cuando el flujo crece, me gusta combinar esto con runbooks sencillos. Ese enfoque encaja con estos runbooks de email que escalan, porque obligan a documentar qué estados importan de verdad y cuál es la acción esperada en cada uno.
Como depurar reintentos sin duplicar ruido
Los reintentos ayudan, pero si no se diseñan bien esconden el fallo en vez de aclararlo. Un error comun es registrar el mismo mensaje completo en cada vuelta. Eso infla los logs y hace más dificil detectar la causa original.
Prefiero esta secuencia:
- un evento corto cuando se agenda el reintento
- un evento corto cuando empieza el nuevo intento
- un evento final con resultado y duración
Con eso puedes responder preguntas utiles muy rapido:
- ¿cuántos jobs terminan en el segundo intento?
- ¿qué plantilla falla más?
- ¿el proveedor tarda más en ciertas horas?
Si además guardas un resumen final por job, el soporte puede revisar un caso sin leer todo el stream. No necesitas perfección; necesitas señales consistentes. Esa pequeña diferencia hace que una cola de email pase de "mas o menos estable" a algo que el equipo puede operar con calma.
Preguntas frecuentes
¿Necesito tracing distribuido para esto?
No al principio. Con request_id, job_id y eventos bien nombrados ya puedes resolver una parte grande del problema.
¿Cuándo separar logs de negocio y logs técnicos?
Lo antes posible. Los logs de negocio explican qué quería hacer el sistema; los técnicos explican cómo salió ese intento. Juntarlos suele crear confusión.
¿Qué revisaría primero si faltan emails?
Buscaría jobs aceptados sin evento final, luego tiempos altos por proveedor y después errores repetidos por plantilla. Ese orden normalmente te lleva al problema real bastante mas rapido.
Si tu cola ya envia emails pero nadie confía en sus señales, empezar por los logs correctos suele dar más retorno que cambiar de librería. No es glamoroso, pero si muy efectivo.
Top comments (0)