DEV Community

Silviu Technology
Silviu Technology

Posted on

FastAPI: reenvios seguros en signup

En muchos equipos el problema no es enviar el correo de bienvenida, sino reenviarlo sin querer tres veces cuando el usuario toca refresh, abre otra pestaña o el frontend repite la llamada por timeout. En FastAPI eso pasa mas seguido de lo que parece, y luego soporte recibe el clasico "me llegaron varios correos". No suena grave, pero desgasta bastante el signup y deja logs medio feos.

Mi regla simple es esta: el endpoint de registro no deberia pensar solo en "enviar email", tambien deberia decidir si ese email ya esta en camino. Cuando ese criterio vive en el backend, el flujo se vuelve mas estable y mucho mas facil de explicar.

Por que se duplican los correos de signup

La secuencia normal suele verse asi:

  1. El usuario manda el formulario.
  2. La API crea la cuenta o deja el signup en proceso.
  3. Se dispara un email de verificacion en background.
  4. El cliente no recibe señal clara y reintenta.

Si no existe una llave de idempotencia o una ventana corta de enfriamiento, cada intento puede abrir otro envio. A veces el frontend lo causa; otras veces es un worker que reintenta sin revisar estado previo. El resultado se parece mucho a un bug raro, pero en realidad es un contrato incompleto.

Esto tambien se nota cuando intentas validar correos durante cambios de infraestructura. Si no sabes si el problema fue el deploy, el proveedor o un reenvio repetido, la investigacion tarda mas de la cuenta.

Un patron pequeno para reenvios seguros

No hace falta montar una arquitectura enorme. Con tres decisiones ya mejora bastante:

  1. Generar una clave estable por intento de signup.
  2. Guardar el estado del envio con una expiracion corta.
  3. Reusar el estado si llega otra solicitud igual en pocos minutos.

Un ejemplo minimo:

from datetime import datetime, timedelta, timezone
from fastapi import BackgroundTasks, FastAPI
from pydantic import BaseModel
import hashlib

app = FastAPI()
EMAIL_JOBS: dict[str, dict] = {}


class SignupRequest(BaseModel):
    email: str
    locale: str = "es"


def job_key(email: str) -> str:
    return hashlib.sha256(email.strip().lower().encode()).hexdigest()


def send_signup_email(key: str, email: str) -> None:
    EMAIL_JOBS[key]["status"] = "sending"
    EMAIL_JOBS[key]["updated_at"] = datetime.now(timezone.utc).isoformat()
    # aqui iria el proveedor real
    EMAIL_JOBS[key]["status"] = "sent"
    EMAIL_JOBS[key]["updated_at"] = datetime.now(timezone.utc).isoformat()


@app.post("/signup/email")
def signup_email(payload: SignupRequest, tasks: BackgroundTasks):
    key = job_key(payload.email)
    now = datetime.now(timezone.utc)
    current = EMAIL_JOBS.get(key)

    if current:
        locked_until = datetime.fromisoformat(current["locked_until"])
        if locked_until > now:
            return {"status": current["status"], "reused": True}

    EMAIL_JOBS[key] = {
        "status": "queued",
        "locked_until": (now + timedelta(minutes=3)).isoformat(),
        "updated_at": now.isoformat(),
    }
    tasks.add_task(send_signup_email, key, payload.email)
    return {"status": "queued", "reused": False}
Enter fullscreen mode Exit fullscreen mode

No es el diseño final para produccion, claro. Pero la idea base ya funciona: si llega la misma peticion mientras el envio sigue "vivo", el backend responde con el mismo estado en lugar de abrir otro correo. Es una mejora chica, pero evita bastante ruido y hace que el sistema se sienta mas serio.

Que datos conviene guardar

Cuando armo esto en un backend real, casi siempre guardo:

  1. job_key o attempt_key
  2. estado actual: queued, sending, sent, failed
  3. locked_until
  4. provider_message_id si existe
  5. last_error si algo sale mal

Con eso puedes responder soporte, depurar colas y decidir si un boton de "reenviar" debe habilitarse o no. Tambien ayuda a manejar la espera de correo asincrono sin dejar al usuario mirando una pantalla vacia. El frontend no necesita adivinar: consulta estado, muestra un mensaje claro y listo.

Otro detalle util es distinguir entre correo de producto y correo de prueba. En QA aparecen direcciones de direccion de correo temporal, cuentas de staging y texto bastante raro, por ejemplo tem email o tempail mail pegados en notas, seeds o capturas. Si eso no queda etiquetado, luego cuesta entender que fue trafico real y que fue una prueba medio improvisada.

Como probar el flujo sin confundir evidencia

Aqui me funciona un checklist corto:

  1. Crear un caso donde el cliente repita la peticion dos o tres veces.
  2. Verificar que solo exista un job activo por email normalizado.
  3. Confirmar que el estado pueda consultarse hasta terminar.
  4. Separar pruebas tecnicas de metricas de onboarding real.

Si haces esto temprano, el equipo deja de perseguir "bugs fantasma". Muchas veces el envio estaba bien; lo roto era la falta de memoria entre intentos. Y eso, sinceramnte, es bastante comun en sistemas que crecieron rapido.

Tambien conviene devolver un texto humilde pero claro, algo como: "Ya estamos procesando tu correo de verificacion". No es marketing brillante, pero evita el click nervioso. Ese pequeno detalle reduce reintentos tontos y deja al usuario mas tranquilo.

Preguntas frecuentes

Conviene una tabla SQL o alcanza Redis?

Para una ventana corta de bloqueo, Redis va muy bien. Si necesitas auditoria o analisis posterior, prefiero tabla SQL con timestamps y estado final. Depende del tipo de producto, pero ambas opciones sirven.

Cuanto deberia durar el bloqueo?

Para signup normal, 2 a 5 minutos suele alcanzar. Menos tiempo deja escapar duplicados; mucho mas tiempo puede frustrar a quien de verdad necesita reenviar.

Esto reemplaza una cola de trabajos?

No. Lo complementa. La cola sigue siendo responsable de ejecutar el envio. Esta capa solo decide si tiene sentido abrir otro intento o reusar el que ya existe.

Si tu API de signup hoy envia correos "a lo que salga", empezar por este patron suele dar una mejora rapida. No es glamoroso, pero si quita ruido, ordena el backend y evita una clase de error que aparece mas de lo que deberia.

Top comments (0)