DEV Community

Olivia Cheng
Olivia Cheng

Posted on

FastAPI: idempotencia en emails de prueba

Las pruebas de registro que dependen de un email suelen fallar de una forma dificil de explicar. El test crea un usuario, solicita un mensaje de verificación y espera una señal. Si la consulta tarda, el runner reintenta. Entonces aparecen dos usuarios, dos mensajes o un resultado que ya no sabemos a qué ejecución pertenece.

En mis scripts de backend, trato cada email de prueba como una operación idempotente. La idea es simple: repetir la misma petición debe devolver el mismo resultado lógico, aunque el proveedor tarde o la automatización pierda una respuesta. Esto hace que un endpoint para validar emails de prueba sin ruido sea mucho más util en CI.

El problema de repetir una prueba de email

Imagina este flujo:

crear usuario -> pedir verificación -> revisar buzón -> confirmar usuario
Enter fullscreen mode Exit fullscreen mode

Un timeout entre “pedir verificación” y “revisar buzón” no significa que la primera operación no ocurrió. Sin un identificador estable, el test no puede distinguir entre una petición pendiente y una nueva petición. La reaccion habitual es repetir todo el flujo, y ahi nace el duplicado.

Hay dos claves para evitarlo:

  • El cliente envía una clave de idempotencia por ejecución.
  • El servidor guarda el estado y el resultado asociado a esa clave.

La clave no debe ser el email solamente. Un mismo buzón puede servir para varias ejecuciones y una prueba nueva necesita su propio contexto. Una combinación como run_id + caso_de_prueba suele ser suficiente para empezar.

Un contrato idempotente para el endpoint

El endpoint puede aceptar una petición pequeña y explicita:

{
  "email": "qa-42@example.test",
  "test_case": "signup_verification",
  "idempotency_key": "run-2026-10-20-42"
}
Enter fullscreen mode Exit fullscreen mode

Para la misma idempotency_key, la API debería devolver uno de estos resultados:

  • 201 created si acaba de registrar la solicitud.
  • 200 existing si la solicitud ya estaba completa.
  • 202 pending si sigue en proceso y todavía no hay resultado final.
  • 409 conflict si la clave se reutilizó con un email o caso diferente.

El 409 es importante. Si una clave cambia de significado en mitad de una prueba, ocultar el problema detrás de un 200 produce diagnósticos confusos. Un mensaje de error claro ahorra bastante tiempo despues.

Implementación pequeña con FastAPI

Para una prueba local no hace falta construir un sistema enorme. Un repositorio en memoria permite probar el contrato antes de elegir PostgreSQL o Redis:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr

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


class VerificationRequest(BaseModel):
    email: EmailStr
    test_case: str
    idempotency_key: str


@app.post("/verification-requests")
def create_request(payload: VerificationRequest):
    saved = requests.get(payload.idempotency_key)
    if saved:
        same_input = (
            saved["email"] == str(payload.email)
            and saved["test_case"] == payload.test_case
        )
        if not same_input:
            raise HTTPException(status_code=409, detail="Clave reutilizada")
        return {"status": "existing", **saved}

    record = {
        "email": str(payload.email),
        "test_case": payload.test_case,
        "status": "pending",
    }
    requests[payload.idempotency_key] = record
    return {"status": "created", **record}
Enter fullscreen mode Exit fullscreen mode

En producción, la escritura debe tener una restricción única sobre idempotency_key. El patrón de “buscar y luego insertar” puede sufrir una carrera cuando dos workers reciben la misma petición al mismo tiempo. La base de datos tiene que ser la ultima defensa, no sólo el código Python.

Cómo conectar la automatización sin duplicados

La automatización puede separar tres pasos: reservar la solicitud, esperar la señal y confirmar el resultado. Cada paso consulta el estado existente antes de hacer trabajo nuevo.

def wait_for_verification(client, key: str, timeout: float = 30):
    request = client.get(f"/verification-requests/{key}")
    if request["status"] == "confirmed":
        return request

    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        message = client.find_message(key=key)
        if message:
            return client.confirm(key=key, message_id=message["id"])
        time.sleep(1)

    return {"status": "pending", "key": key}
Enter fullscreen mode Exit fullscreen mode

El worker no deberia crear otra solicitud al recibir pending. Puede guardar el último momento de consulta y devolver el control al runner. En un sistema que necesita un correo temporal desechable para cada caso, conviene usar formularios async que respetan el foco en la interfaz de pruebas, pero la misma regla aplica: una ejecución, una clave, un estado observable.

Si el equipo necesita generar un correo desechable, esa acción debe quedar fuera de la clave de idempotencia o asociarse a ella de forma explícita. De lo contrario, un reintento puede reservar otro buzón y perder el mensaje original. En mis notas he visto nombres como temp mailid y temp org mail; aunque parezcan equivalentes, conviene conservar el identificador que realmente devolvió el adaptador.

También es útil registrar created_at, expires_at, message_id y un código de error del proveedor. No guardaría el contenido completo del mensaje si sólo se necesita verificar un token. Menos datos hacen el debugging mas seguro y reducen el ruido en los logs.

Preguntas rápidas

¿Una clave de idempotencia debe durar para siempre?

No. Debe durar lo suficiente para cubrir los reintentos de la ejecución. Un TTL evita que el almacén crezca sin limite y permite reutilizar nombres de pruebas antiguas con un nuevo run_id.

¿Qué hacemos si el mensaje llega después del timeout?

Marca el caso como pending o expired, pero conserva la evidencia. Un proceso de limpieza puede retirar el buzón y el runner puede decidir si reintenta toda la prueba. No conviertas un timeout en “email inexistente” sin comprobar el estado del proveedor.

¿Esto sustituye una prueba end-to-end?

No. Este patrón mejora la confiabilidad del flujo y hace sus fallos más legibles. Todavía conviene tener algunas pruebas end-to-end con un proveedor real y otras unitarias con un adaptador falso.

Checklist antes de llevarlo a CI

  • [ ] Cada ejecución genera un run_id único.
  • [ ] La clave de idempotencia combina ejecución y caso de prueba.
  • [ ] Reutilizar la clave con datos distintos devuelve 409.
  • [ ] La base de datos protege la inserción concurrente.
  • [ ] pending, expired y provider_error son estados distintos.
  • [ ] El token o contenido sensible no aparece en logs.
  • [ ] El cleanup conoce la caducidad del buzón y de la solicitud.

No hace falta añadir mucha infraestructura para obtener el beneficio. Con un contrato pequeño, estados claros y reintentos que consultan antes de crear, las pruebas de email dejan de depender de la suerte. La automatización se vuelve mas predecible y el diagnóstico, bastante mas rapido.

Top comments (0)