DEV Community

Silviu Technology
Silviu Technology

Posted on

FastAPI: contratos claros para tareas de email

Una tarea de email no termina cuando la API responde 202 Accepted. En ese momento solo sabemos que el backend aceptó una intención. Todavía falta enviar, reintentar, registrar el resultado y, si algo sale mal, explicar que pasó.

En proyectos Python con FastAPI he encontrado que los fallos más caros no son los errores de SMTP. Son los contratos ambiguos: una tarea no sabe si puede repetirse, el worker no distingue un timeout de un rechazo y el equipo termina mirando logs que no dicen mucho. El mismo problema aparece al probar flujos con un correo temporal o un disposable email address: si no guardamos contexto, no podemos saber si falló el producto o el buzón de prueba.

Este patrón complementa las pruebas limpias de email con FastAPI y los reenvíos de verificación más tranquilos. La idea es simple: cada tarea debe llevar un contrato pequeño y visible.

El problema de las tareas de email ambiguas

Una función como send_email(user_id) parece suficiente, pero deja preguntas importantes sin responder:

  • ¿Se puede repetir sin enviar dos mensajes?
  • ¿Qué identificador conecta el intento con el usuario?
  • ¿Cuándo debe reintentarse y cuándo no?
  • ¿Qué respuesta debe guardar el worker?

Cuando estas decisiones viven solo en la cabeza de quien escribió el código, cada integración las interpreta distinto. Un equipo puede reintentar un 400 como si fuera un timeout, mientras otro descarta un error temporal demasiado pronto. Es inconsistant y difícil de mantener.

Un contrato pequeño para cada tarea

Podemos representar el mensaje de trabajo con un modelo explícito. No hace falta crear una plataforma enorme para empezar:

from pydantic import BaseModel, EmailStr

class EmailTask(BaseModel):
    task_id: str
    recipient: EmailStr
    template: str
    idempotency_key: str
    attempt: int = 0
Enter fullscreen mode Exit fullscreen mode

El task_id sirve para seguir la ejecución. La idempotency_key protege contra duplicados cuando una respuesta se pierde después de que el proveedor aceptó el mensaje. attempt debe aumentar en cada intento, no cada vez que alguien pulsa el botón de la interfaz.

El endpoint puede aceptar la tarea y dejar el envío a un worker:

from fastapi import BackgroundTasks, FastAPI

app = FastAPI()

@app.post("/email-tasks", status_code=202)
async def create_email_task(task: EmailTask, jobs: BackgroundTasks):
    await save_task(task)
    jobs.add_task(process_email_task, task.task_id)
    return {"task_id": task.task_id, "status": "queued"}
Enter fullscreen mode Exit fullscreen mode

La respuesta es deliberadamente pequeña. El cliente recibe un estado, no una promesa de entrega inmediata.

Reintentos seguros con FastAPI

No todos los errores merecen un reintento. Un timeout de red o una respuesta 429 suelen ser temporales. Una dirección inválida, en cambio, necesita una decisión de negocio. Reintentarla cinco veces solo añade ruido.

Una regla útil es guardar el resultado de cada intento antes de programar el siguiente. Así, una caída del worker no borra la historia:

async def process_email_task(task_id: str) -> None:
    task = await load_task(task_id)
    if task.status in {"sent", "permanent_failure"}:
        return

    try:
        result = await provider.send(task)
        await mark_sent(task, provider_id=result.id)
    except TemporaryEmailError as error:
        await record_attempt(task, error=str(error))
        await schedule_retry(task)
    except PermanentEmailError as error:
        await mark_permanent_failure(task, reason=str(error))
Enter fullscreen mode Exit fullscreen mode

El retorno temprano es importante: hace que el proceso sea idempotente incluso si dos workers reciben la misma tarea. En producción también conviene usar una restricción única para idempotency_key, porque una comprobación en Python sola puede sufrir una carrera.

Observabilidad que ayuda a depurar

Los logs deben contestar tres preguntas: qué tarea falló, en qué intento y qué decisión tomó el sistema. Un mensaje como email failed no alcanza. Incluye task_id, attempt, tipo de error, latencia y un identificador del proveedor; nunca incluyas el contenido completo del mensaje o datos privados innecesarios.

Si haces pruebas con tempmailso, registra además el entorno y la ventana de polling. No confundas tepm mail com con una dirección real durante una revisión manual: los pequeños errores de escritura también pueden contaminar un diagnóstico. Para pruebas repetibles, guarda un recibo de ejecución con estado, timestamps y razón final.

Checklist de implementación

Antes de publicar este flujo, revisa:

  1. Cada tarea tiene un ID y una clave de idempotencia.
  2. Los errores temporales y permanentes tienen rutas distintas.
  3. El intento se guarda antes de volver a poner la tarea en cola.
  4. Una tarea enviada no puede enviarse otra vez por un retry tardío.
  5. Los logs permiten encontrar la tarea sin exponer contenido sensible.
  6. Las pruebas cubren timeout, 429, dirección inválida y duplicación.

Un contrato pequeño hace que la automatización sea más confiable sin convertir el backend en un laberinto. FastAPI aporta la velocidad para construir el endpoint; la claridad del estado, los reintentos y la evidencia es lo que permite operarlo con tranquilidad.

Top comments (0)