Cuando una API de registro o verificación falla a medias, el equipo suele reintentar rapido. El problema es que ese segundo intento muchas veces manda un correo extra aunque el primero ya estaba en cola. En FastAPI esto pasa bastante cuando el endpoint responde antes de que el worker termine y nadie define una clave idempotente para el envio.
En proyectos pequeños no hace falta montar una arquitectura enorme para corregirlo. Lo que sí conviene hacer es separar dos decisiones: "la app debe enviar este correo" y "el proveedor ya recibió este correo". Esa distinción evita varios sustos, sobre todo cuando soporte empieza a ver usuarios con dos links de verificación distintos y no sabe cual abrió primero.
Por que los reintentos duplican correos
El patrón que veo más seguido es este:
- El cliente llama
POST /verify-email. - La API crea un token y encola el envio.
- La conexión se corta o el worker tarda más de lo esperado.
- El cliente reintenta.
- El backend vuelve a crear otro envio porque no sabe que el primero ya existía.
Eso genera ruido para producto, para soporte y para métricas. También rompe pruebas de staging, porque una bandeja compartida ya no deja claro qué mensaje corresponde al escenario actual. En temas de trazabilidad, me gusta bastante esta idea de automatizacion de correos entre servicios: si el flujo cruza varios componentes, hay que dejar un identificador estable y visible entre ellos.
Un patron simple para volver idempotente el envio
La versión más simple que me ha funcionado usa una tabla email_jobs con una clave única por intención de negocio. Por ejemplo:
user_idtemplateintent_keystatusprovider_message_id
intent_key puede ser algo como verify-email:<user_id>:<version>. Si entra el mismo request otra vez, la API no crea un nuevo job. Solo devuelve el existente o confirma que el envío ya está programado. Ese pequeño detalle suele bajar muchisimo los duplicados.
Si además quieres una bandeja temporal para validar el flujo completo de extremo a extremo, un correo burner puede servir para aislar pruebas manuales o smoke tests. No lo volvería el centro del sistema, pero ayuda cuando necesitas comprobar que solo salió un mensaje real y no tres variaciones casi iguales.
Lo importante es que la idempotencia viva en tu backend, no en la paciencia del equipo. Si dependes de "ojalá el cliente no reintente" o "ojalá el proveedor ignore duplicados", tarde o temprano pagas ese atajo.
Ejemplo pequeno con FastAPI y PostgreSQL
Una implementación minima puede verse así:
from fastapi import FastAPI, HTTPException
app = FastAPI()
def build_intent_key(user_id: str, version: int) -> str:
return f"verify-email:{user_id}:{version}"
@app.post("/verify-email")
def send_verification_email(user_id: str, version: int = 1):
intent_key = build_intent_key(user_id, version)
job = find_email_job(intent_key)
if job:
return {"ok": True, "status": job["status"], "intent_key": intent_key}
created = create_email_job(
user_id=user_id,
template="verify-email",
intent_key=intent_key,
)
if not created:
raise HTTPException(status_code=409, detail="job_conflict")
enqueue_email_job(intent_key)
return {"ok": True, "status": "queued", "intent_key": intent_key}
No es un sistema completo, claro, pero muestra la idea central: primero registras la intención, luego encolas el trabajo. Si el request vuelve, respondes usando el mismo intent_key. En PostgreSQL esto suele quedar bien con un índice único y un INSERT ... ON CONFLICT DO NOTHING.
Donde varios equipos se tropiezan es en regenerar el token cada vez que reintentan. Ahí el correo A y el correo B ya no son equivalentes, así que el usuario recibe dos links validos o uno válido y otro vencido. Ese escenario es molesto de depurar y bastante feo para la experiencia final, la verdad.
Como probarlo sin contaminar bandejas
Para verificar que la solución funciona, intentaría algo muy concreto:
- Crear un usuario de prueba nuevo.
- Llamar dos veces al endpoint con el mismo
intent_key. - Confirmar que solo existe un
email_job. - Revisar que la bandeja recibe un solo mensaje.
- Abrir el link y validar el estado final del usuario.
Si ya vienes probando flujos parecidos, te puede servir este artículo sobre probar emails transaccionales en FastAPI. La lección que más rescato es no mezclar trazabilidad con intuición: si API, worker y bandeja no comparten una clave, cualquier debug termina siendo medio artesanal.
También pondría atención a las notas improvisadas del equipo. Cuando en Slack o en un ticket aparece algo como tepm mail com, normalmente no es solo un typo gracioso. Suele ser la señal de que el procedimiento manual todavía está difuso y cualquiera puede terminar revisando una bandeja incorrecta o un run viejo.
Preguntas frecuentes
¿Debo resolverlo con locks distribuidos?
No siempre. Para muchos productos basta una clave única de negocio y una cola razonable. Los locks ayudan cuando hay mucha concurrencia real, pero meterlos demasiado pronto complica el backend sin necesidad.
¿BackgroundTasks alcanza?
Para volumen bajo, sí. Si necesitas reintentos, observabilidad y control más fino, prefiero un worker dedicado con una tabla de jobs. Es menos elegante en teoria, pero en producción suele ser más facil de operar.
¿Qué reviso primero si siguen saliendo duplicados?
Primero miraría si el intent_key cambia entre intentos. Después revisaría si el worker vuelve a crear el envio en vez de consumir el job ya registrado. Es un fallo pequeño, pero aparece más veces de lo que deberia.
Top comments (0)