Silviu TechnologyGuia practica para acotar espera, reintentos y visibilidad en emails async con FastAPI sin volver fragiles tus pruebas.
En muchos backends con FastAPI el email se manda fuera del request y eso está bien. El problema aparece cuando nadie define cuánto vale la pena esperar, cuándo reintentar y en qué momento una prueba debe fallar con una señal clara. Sin ese presupuesto, soporte mira logs, QA repite escenarios y producto siente que el flujo "a veces va" y a veces no. No es un bug raro; es una falta de contrato, y pasa bastnate.
Yo intento resolverlo con una idea simple: cada email asíncrono necesita un estado visible y una ventana de espera explícita. Si un signup debe confirmar correo en 20 segundos, dilo en código, en métricas y en pruebas. Si a veces tarda 2 minutos por diseño, dilo tambien. Lo importante es dejar de tratar la espera como algo infinito.
Lo más común no es fallar al enviar, sino fallar al decidir cuándo algo ya tardó demasiado.
Cuando el backend solo responde 202 Accepted, todo el mundo completa el resto con su imaginación:
Ese patrón además vuelve frágiles las pruebas con email desechable. Un test no necesita esperar para siempre hasta ver un mensaje; necesita saber qué ventana es razonable para ese flujo. Si no, el caso termina validando paciencia, no software.
Mi versión mínima del contrato tiene dos partes:
Los estados suelen bastar con queued, sending, sent y failed. El presupuesto de espera puede ser algo como expected_within_seconds. Esto cambia la conversación del equipo porque ya no preguntas "¿salió el correo?", sino "¿salió dentro del presupuesto?".
También ayuda a separar incidentes reales de ruido. Si un correo de bienvenida tiene objetivo de 15 segundos y hoy tarda 18, probablemente no sea grave. Si tarda 90, sí quieres alertar. Esa diferencia parece obvia, pero cuando no queda escrita nadie la recuerda igual, y ahi nacen tickets medio confusos.
Este ejemplo es pequeño, pero enseña bien la idea:
from enum import StrEnum
from fastapi import FastAPI, BackgroundTasks, HTTPException
from pydantic import BaseModel
from time import time
from uuid import uuid4
app = FastAPI()
jobs: dict[str, dict] = {}
class EmailState(StrEnum):
QUEUED = "queued"
SENDING = "sending"
SENT = "sent"
FAILED = "failed"
class EmailRequest(BaseModel):
to: str
template: str
def send_job(job_id: str) -> None:
job = jobs[job_id]
job["state"] = EmailState.SENDING
try:
deliver_email(job["to"], job["template"])
job["state"] = EmailState.SENT
except Exception as exc:
job["state"] = EmailState.FAILED
job["error"] = str(exc)
finally:
job["updated_at"] = int(time())
@app.post("/email-jobs")
def create_email_job(payload: EmailRequest, tasks: BackgroundTasks):
job_id = str(uuid4())
jobs[job_id] = {
"to": payload.to,
"template": payload.template,
"state": EmailState.QUEUED,
"expected_within_seconds": 20,
"updated_at": int(time()),
}
tasks.add_task(send_job, job_id)
return {"job_id": job_id, **jobs[job_id]}
@app.get("/email-jobs/{job_id}")
def get_email_job(job_id: str):
job = jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="job not found")
return job
Con eso ya puedes hacer polling corto desde UI o desde una prueba. Yo suelo limitarlo a 20 o 30 segundos con pasos de 2 segundos. Si no llegó en esa ventana, la prueba falla con evidencia útil y no se queda colgada media vida. Parece una mejora chica, pero quita mucho desgaste.
Aquí veo otro error comun: querer verificar inbox real en todos los escenarios. No hace falta. Para la mayoría de pruebas de API basta validar transición de estado, timestamps y payload esperado. Deja las comprobaciones de inbox real para unas pocas pruebas end-to-end.
Ese reparto baja flakes y además ayuda a leer fallos. Si un caso usa textos internos como fake e mail com o dummy e mail para nombrar escenarios, está bien mientras esos marcadores no sustituyan la telemetría real. La verdad operativa debe seguir en tu job store, no en notas sueltas o screenshots.
Me gusta conectar este patrón con otras piezas del stack. Si ya tienes senales claras cuando cambia el estado en procesos operativos, aplicar esa misma disciplina a correo suele salir natural. Y si antes trabajaste en probar emails sin romper tu entorno, el siguiente paso lógico es acotar cuánto esperas y qué evidencia guardas cuando no llega.
Según Google, definir SLOs y señales observables reduce el tiempo perdido en diagnósticos porque el equipo deja de adivinar y empieza a comparar contra objetivos explícitos source. No arregla entregas lentas por si solo, pero si vuelve la conversación mucho más util.
Esto es lo que intento dejar listo desde el primer PR:
job_id visible desde la respuesta inicialexpected_within_seconds por flujoSi puedes medir percentiles mejor aún. El benchmark State of DevOps suele insistir en ciclos de feedback cortos porque reducen retrabajo y hacen más rápido el diagnóstico source. En correo asíncrono aplica igual, aunque a veces se nos olvida.
BackgroundTasks alcanza?
Para cargas moderadas, sí. Para flujos críticos o mucho volumen, mejor mover el envío a un worker separado. El contrato puede quedarse igual y eso simplifica bastante la migración.
Lo mínimo que cubra tu comportamiento normal más un margen pequeño. Si el promedio es 4 segundos, no pongas 3. Pero tampoco 180 "por si acaso". Ese tipo de timeout gigante suele esconder problemas, no resolverlos.
Primero la claridad. Cuando estado, tiempo esperado y evidencia quedan visibles, backend, soporte y QA discuten menos y arreglan antes. No suena glamoroso, pero funciona muy bien en equipos reales.