En muchos productos el botón de "reenviar email" parece una tarea menor, pero termina creando bastante ruido. El usuario toca dos veces, el frontend reintenta, el worker tarda un poco y el backend acaba enviando dos o tres mensajes que compiten entre sí. Luego soporte no sabe cuál link era el bueno, QA ve resultados inestables y el equipo pierde tiempo en un problema bastnate evitable.
En FastAPI yo intento resolverlo con un contrato muy chico: un cooldown corto para reenviar, una clave idempotente por intento y un estado consultable desde API. No hace falta una arquitectura exótica. Hace falta dejar claro cuándo se puede reenviar, qué intento sigue vivo y qué evidencia devuelve el sistema si algo tarda más de lo normal.
Donde nacen los reenvios duplicados
El problema no suele ser solo "el usuario hizo doble clic". Normalmente aparece por una mezcla de cosas:
- la UI no sabe si el primer request ya quedó aceptado
- el backend crea un nuevo job aunque el anterior siga activo
- el worker no marca cuál email reemplaza a cuál
- nadie define una ventana mínima antes de permitir otro resend
Cuando eso pasa, el sistema sigue funcionando a medias, pero cada capa interpreta algo distinto. Producto cree que el usuario recibió ayuda. Backend cree que cumplió con el 202 Accepted. Soporte recibe capturas confusas. Y QA, bueno, QA sube el timeout y reza un poco.
Un contrato pequeno para reenviar sin caos
Mi versión mínima del contrato tiene cuatro datos:
email_intent_idstateresend_available_atsuperseded_by
Con eso ya puedes responder preguntas utiles. ¿Hay un intento vigente? ¿Todavía está dentro del cooldown? ¿El correo anterior fue reemplazado por uno nuevo? ¿Qué intento debe revisar una prueba end-to-end?
También me gusta guardar un idempotency_key derivado del usuario y del tipo de flujo, no del clic actual. Así si llegan dos requests casi juntos, la API puede responder con el mismo intento o rechazar el segundo con una explicación clara. No es una bala mágica, pero ordena bastante.
Si además expones resend_available_at, el frontend deja de improvisar timers. Ese detalle parece menor, pero baja muchisimo el ruido entre UI y backend.
Ejemplo con FastAPI
Este ejemplo enseña bien la idea sin meter demasiada infraestructura:
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from uuid import uuid4
app = FastAPI()
intents: dict[str, dict] = {}
active_by_user: dict[str, str] = {}
COOLDOWN = timedelta(seconds=45)
class ResendRequest(BaseModel):
user_id: str
email: str
@app.post("/signup-email/resend")
def resend_signup_email(payload: ResendRequest):
now = datetime.now(timezone.utc)
active_id = active_by_user.get(payload.user_id)
if active_id:
current = intents[active_id]
available_at = datetime.fromisoformat(current["resend_available_at"])
if now < available_at:
raise HTTPException(
status_code=409,
detail={
"message": "resend cooldown active",
"email_intent_id": active_id,
"resend_available_at": current["resend_available_at"],
"state": current["state"],
},
)
intent_id = str(uuid4())
intents[intent_id] = {
"user_id": payload.user_id,
"email": payload.email,
"state": "queued",
"created_at": now.isoformat(),
"resend_available_at": (now + COOLDOWN).isoformat(),
"superseded_by": None,
}
if active_id:
intents[active_id]["superseded_by"] = intent_id
active_by_user[payload.user_id] = intent_id
enqueue_signup_email(intent_id)
return {"email_intent_id": intent_id, **intents[intent_id]}
No cubre todo, pero ya evita dos errores comunes: aceptar reenvíos infinitos y perder la relación entre intentos viejos y nuevos. Si luego mueves el envío a Celery, RQ o cualquier cola, el contrato puede quedarse casi igual. Eso me gusta porque reduce rehacer la API cuando el volumen crece un poquito.
Como probar el flujo sin ruido
Aquí conviene separar pruebas de API y pruebas de inbox real. La mayoría de casos deberían validar:
- que el segundo resend dentro del cooldown devuelve
409 - que la respuesta incluye
resend_available_at - que un intento viejo queda con
superseded_by - que el intento activo cambia solo una vez
Las pruebas con inbox real déjalas para unos pocos caminos críticos. Si quieres revisar el correo final en un entorno aislado, herramientas como tempmailso pueden servir para no mezclar bandejas de equipo con automatización de signup. Aun así, yo no pondría esa verificación en cada test. Para la mayoria de suites, basta confirmar estados, timestamps y trazas del intento correcto.
En esa parte también ayuda usar nombres feos pero reconocibles para escenarios internos, como tempail mail o dummy e mail, siempre que esos textos no sustituyan la evidencia real del sistema. La fuente de verdad debe seguir en la API y en tu store de intents.
Si te ha funcionado construir senales claras en cada cambio de estado en tareas operativas, el mismo principio vale aqui. Y si ya pasaste por validar correos despues de un cambio delicado, seguramente notarás que los reenvíos necesitan el mismo nivel de contrato, no solo "volver a intentar".
Google Cloud remarca que los sistemas confiables mejoran cuando cada servicio expone estados observables y objetivos explícitos, porque eso reduce diagnósticos a ciegas source. No habla solo de email, claro, pero el principio encaja muy bien en este flujo.
Checklist corto para dejarlo estable
Esto es lo que yo intentaría dejar listo en el primer PR:
- un
email_intent_idvisible desde la primera respuesta - cooldown devuelto por API, no calculado solo en frontend
- relación
superseded_byentre intentos - error
409con contexto legible - una sola prueba de inbox real por camino crítico
- logs que apunten al intento correcto y no solo al usuario
No es un sistema enorme, pero cambia mucho la operación diaria. Soporte entiende qué pasó. QA deja de pelear con flakes. Backend puede medir cuánto tarda cada resend de verdad. Y el usuario recibe una experiencia más consistente, incluso cuando el proveedor de correo va un poco lento o el worker viene medio cargado.
Preguntas rapidas
¿Debo borrar el intento anterior?
Yo no lo haría de inmediato. Mejor marcarlo como reemplazado. Ese historial chico ayuda bastante cuando una prueba o un ticket llega unas horas después.
¿Cuánto debería durar el cooldown?
Lo suficiente para evitar spam accidental, pero no tanto como para frustrar al usuario. En muchos flujos, 30 a 60 segundos esta bien para empezar.
¿Hace falta inbox real en staging?
Sí, pero poco. Uno o dos checks end-to-end suelen alcanzar. El resto debería probar el contrato de API, porque ahi es donde realmente decides si el resend es sano o caótico.
Top comments (0)