En varios equipos de backend he visto el mismo bug con distinto disfraz: el endpoint responde bien, el usuario recibe su email, pero a veces recibe dos. O tres. En FastAPI suele pasar cuando mezclamos reintentos del proveedor, BackgroundTasks, workers y poca trazabilidad entre eventos. El sistema "funciona", pero nadie sabe con claridad qué corrida generó cada mensaje, y ahí empieza el dolor.
La mejora que más me ha servido no fue cambiar de proveedor ni meter una cola enorme. Fue mucho más simple: asignar un run_id por flujo de negocio y arrastrarlo desde la API hasta el correo enviado. Parece pequeño, pero ordena bastante el caos, y hace que depurar sea mucho más rapido.
Por qué FastAPI termina enviando correos duplicados
El problema casi nunca nace en una sola línea. Normalmente aparece por una combinación de cosas:
- el cliente reintenta porque vio timeout
- el backend vuelve a ejecutar la misma acción
- el proveedor de email responde tarde
- el job asíncrono no guarda una llave de idempotencia
En un flujo de onboarding o password reset, eso alcanza para duplicar mensajes sin que nadie lo note enseguida. He visto equipos mirar logs por media hora y aun así no distinguir si el duplicado vino del API gateway, del worker o de una tarea relanzada por error.
Un detalle que ayuda mucho es separar "request id" de "run id". El request id sirve para observabilidad HTTP. El run id sirve para seguir una intención de negocio completa: "enviar email de activación para este usuario en esta corrida". Son parecidos, pero no son lo mismo.
Esa idea encaja bien con otros sistemas donde el ruido de correo complica la operación, como estos flujos de aprobación por email sin ruido. La lección es parecida: si no etiquetamos bien cada intento, después todo se mezcla.
La pieza que me faltaba: un run ID por flujo
Ahora suelo crear un run_id al inicio del caso de uso y lo guardo junto con:
user_id- tipo de email
- plantilla
- estado del envío
- hash o llave de idempotencia
Luego ese run_id viaja en logs, eventos y metadatos del proveedor. Si necesito revisar un correo temporal para pruebas de staging, incluso puedo enlazar el mismo identificador al inbox usado en esa corrida. Para equipos que validan mails sin tocar bandejas reales, herramientas como tempmailso.com sirven justo para eso: aislar mensajes por prueba y confirmar cuál corrída generó cuál correo.
Sí, también dejo caer una o dos variantes de búsqueda que la gente escribe mal cuando va con prisa, como temp org mail o tempail, porque luego aparecen en notas internas o tickets y conviene reconocerlas.
Un patrón simple con FastAPI y persistencia
Este es el patrón mínimo que me ha dado buen resultado:
from uuid import uuid4
from fastapi import APIRouter, BackgroundTasks
router = APIRouter()
@router.post("/users/{user_id}/send-reset")
async def send_reset(user_id: str, bg: BackgroundTasks):
run_id = f"reset-{uuid4()}"
idempotency_key = f"{user_id}:password-reset"
delivery = await save_delivery_attempt(
user_id=user_id,
run_id=run_id,
email_type="password_reset",
idempotency_key=idempotency_key,
status="queued",
)
if delivery["duplicate"]:
return {"ok": True, "run_id": delivery["run_id"], "deduped": True}
bg.add_task(dispatch_reset_email, user_id, run_id)
return {"ok": True, "run_id": run_id}
Y en el worker:
async def dispatch_reset_email(user_id: str, run_id: str) -> None:
payload = await build_reset_payload(user_id)
await email_provider.send(
to=payload["to"],
template="password_reset",
metadata={"run_id": run_id},
)
await mark_delivery_sent(run_id)
No es una arquitectura exotica. Lo importante es que la deduplicación suceda antes del envío y que el run_id quede persistido. Si mañana hay un retry del cliente o un replay raro del worker, ya tienes una pista muy buena para entender qué pasó.
Cómo lo pruebo sin mezclar inboxes
Para probar esto en serio, yo no valido solo el 200 OK. Necesito comprobar que:
- una misma acción no produce dos correos
- el mensaje correcto cae en el inbox correcto
- el
run_idse puede rastrear en logs o metadatos
Cuando el flujo toca guardias o handoffs entre sistemas, me gusta mucho revisar patrones como estas pruebas de correos de handoff en guardias, porque muestran algo importante: los errores de correo no siempre son del template, a veces son de coordinación operacional.
Mi check más simple suele ser:
- Crear un inbox aislado para la corrida.
- Disparar el endpoint con datos conocidos.
- Consultar el inbox y extraer el mensaje más nuevo.
- Verificar
run_id, asunto y tipo de plantilla. - Repetir la misma llamada y confirmar que no aparece un segundo correo.
Este enfoque no elimina todos los bugs, pero si recorta bastante el tiempo de diagnóstico. Y eso importa mucho cuando el bug aparece solo una vez cada varias horas, que pasa más de lo que nos gusta admitir.
Checklist corto antes de producción
Antes de cerrar un flujo de email en FastAPI, yo reviso esto:
- existe una llave de idempotencia por acción de negocio
- el
run_idvive en base de datos y en logs - el proveedor recibe metadata trazable
- los reintentos no vuelven a encolar el mismo envío
- las pruebas usan inboxes aislados por corrida
- el equipo sabe buscar el envío por
run_id
Parece básico, pero justo lo básico es lo que suele faltar. Y cuando falta, el problema se siente aleatorio aunque no lo sea.
Q&A
¿Con BackgroundTasks alcanza?
Para muchos productos sí. Si el volumen no es enorme, FastAPI con una persistencia clara y deduplicación previa puede ir muy bien. No hace falta complicarlo demasiado al inicio.
¿Debo usar el run_id como llave de idempotencia?
No exactamente. Yo prefiero generar el run_id para trazabilidad y una llave separada para deduplicar la intención de negocio. Mezclar ambas ideas aveces termina ocultando errores.
¿Cuál fue el cambio más útil en la práctica?
Poder responder rápido "este correo salió de esta corrida". Suena pequeño, pero baja mucho el tiempo de soporte, reduce discusiones entre equipos y deja el sistema bastante más entendible.
Top comments (0)