DEV Community

Silviu Technology
Silviu Technology

Posted on

FastAPI: estado visible para correos async

Cuando un correo transaccional tarda, muchos equipos miran solo el worker o el proveedor. Yo suelo encontrar otro problema mas simple: la API no tiene una forma clara de decir que esta pasando. Entonces soporte pregunta, producto especula y el frontend termina reintentando por ansiedad. No es un fallo dramatico, pero si desgasta mucho.

En proyectos con FastAPI, me ha servido separar dos cosas: el envio del correo y la visibilidad del estado. Parece obvio, pero cuando agregas un endpoint pequeno para consultar progreso, el flujo deja de sentirse opaco. El usuario ve algo util, el equipo depura mas rapido y el backend queda bastante mas ordenado.

El problema real no es enviar, sino saber que paso

Mandar un email async no es dificil. Lo dificil es responder preguntas muy normales:

  1. El correo ya entro a cola o la peticion fallo antes.
  2. El worker lo esta procesando o quedo atascado.
  3. El proveedor acepto el envio o todavia no.
  4. El usuario debe reintentar o solo esperar un poco mas.

Cuando esa informacion no existe, empiezan los parches medio raros: timeouts largos, botones de reenviar sin contexto y logs que nadie entiende del todo. En equipos pequenos eso pasa bastante, y se nota mas cuando mezclas QA, staging y pruebas con direcciones de tempail mail copiadas desde notas rapidas.

Tambien complica revisar incidentes. Si tus eventos de infraestructura ya generan correos de drift con contexto util, pero el sistema de producto no deja ver el estado del email, terminas con dos niveles de opacidad. Ese cruce confunde mas de lo que ayuda.

Un endpoint de estado cambia la conversacion

El patron que mas me gusta es guardar un delivery_id al crear el trabajo y exponer GET /email-status/{delivery_id}. No resuelve todo, pero cambia la conversacion del equipo:

  1. El frontend ya no "espera por fe".
  2. Soporte puede ver si el correo sigue en cola.
  3. El usuario recibe mensajes mas honestos.
  4. Los reintentos dejan de ser un reflejo automatico.

La gracia es que no necesitas acoplarte demasiado al proveedor. Tu API puede hablar en estados propios como queued, sending, sent y failed. Luego mapeas lo que venga de tu worker, de Celery, de una cola interna o del proveedor que uses. Es una capa pequena, pero baja mucho el ruido diario.

Ejemplo pequeno en FastAPI

Este ejemplo usa una memoria en proceso solo para explicar la idea. En produccion lo moveria a Redis o SQL, claro, pero el contrato queda casi igual:

from datetime import datetime, timezone
from fastapi import BackgroundTasks, FastAPI, HTTPException
from pydantic import BaseModel
from uuid import uuid4

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


class EmailRequest(BaseModel):
    email: str
    template: str


def send_email(delivery_id: str, email: str, template: str) -> None:
    DELIVERIES[delivery_id]["status"] = "sending"
    DELIVERIES[delivery_id]["updated_at"] = datetime.now(timezone.utc).isoformat()

    # aqui iria la llamada al proveedor real

    DELIVERIES[delivery_id]["status"] = "sent"
    DELIVERIES[delivery_id]["provider_ref"] = f"mail-{delivery_id[:8]}"
    DELIVERIES[delivery_id]["updated_at"] = datetime.now(timezone.utc).isoformat()


@app.post("/emails")
def create_email_job(payload: EmailRequest, tasks: BackgroundTasks):
    delivery_id = str(uuid4())
    DELIVERIES[delivery_id] = {
        "email": payload.email,
        "template": payload.template,
        "status": "queued",
        "created_at": datetime.now(timezone.utc).isoformat(),
        "updated_at": datetime.now(timezone.utc).isoformat(),
        "provider_ref": None,
    }
    tasks.add_task(send_email, delivery_id, payload.email, payload.template)
    return {"delivery_id": delivery_id, "status": "queued"}


@app.get("/email-status/{delivery_id}")
def email_status(delivery_id: str):
    delivery = DELIVERIES.get(delivery_id)
    if not delivery:
        raise HTTPException(status_code=404, detail="delivery not found")
    return delivery
Enter fullscreen mode Exit fullscreen mode

Lo importante no es el diccionario. Lo importante es el contrato. El cliente recibe un delivery_id, puede consultar estado y deja de depender de sleeps raros. En flujos de Automatizacion esto viene muy bien porque los consumidores no siempre son humanos; a veces son otros servicios, jobs o tests que necesitan una señal simple.

Que campos guardo en produccion

Suelo empezar con estos:

  1. delivery_id
  2. status
  3. provider_ref
  4. created_at
  5. updated_at
  6. last_error
  7. attempt_count

Si el correo participa en signup o recuperacion de cuenta, agrego tambien un subject_type o flow_name. Eso evita mezclar metricas y te deja responder mejor cuando alguien pregunta por que cierto email no llego. Parece un detalle, pero despues ahorra horas de ida y vuelta, sobretodo cuando aparecen reintentos desde clientes viejos o cron jobs medio apurados.

Otra ventaja es que puedes alinear mejor tus mensajes operativos con otros sistemas. Si ya miras correos de rollback sin confusion para infraestructura, tener estados similares en producto hace que el equipo lea todo con el mismo lenguaje. Eso reduce errores tontos, o almenos los hace mas faciles de ver.

Como lo pruebo sin volverme loco

Yo suelo probar este patron con tres escenarios:

  1. El worker completa rapido y el estado pasa de queued a sent.
  2. El worker tarda y el cliente consulta varias veces sin romper nada.
  3. El worker falla y el endpoint devuelve contexto suficiente para decidir si reintentar.

Si puedes, agrega una prueba donde el frontend haga polling cada pocos segundos con backoff corto. Segun Google Web Vitals guidance, reducir esperas bloqueantes y mostrar progreso ayuda a que la experiencia se perciba mas estable, incluso cuando el trabajo real tarda un poco. No hace milagros, pero si ordena la expectativa del usuario.

Tambien me gusta medir tiempo en cola y tiempo hasta proveedor aceptado. En sistemas internos, esos dos numeros suelen explicar casi todo. Cuando alguien dice "el email nunca llego", muchas veces el problema real fue "el estado nunca fue visible". Son cosas distintas, y conviene no mezclarlas.

Preguntas frecuentes

Conviene polling o webhooks?

Para paneles internos y flujos de registro, polling simple suele alcanzar. Webhooks sirven mas cuando otro sistema externo necesita enterarse del cambio.

Redis o Postgres?

Redis va muy bien si solo quieres velocidad y TTL. Postgres me gusta cuando necesito auditoria, joins o revisar historico despues.

Esto reemplaza logs?

No. Los complementa. Los logs cuentan la historia tecnica; el endpoint de estado expone una version corta y consumible para clientes o soporte.

Si tu sistema ya envia correos pero todavia obliga a adivinar, este patron merece una tarde de trabajo. No es una refactor enorme, y sin embargo deja un backend mucho mas legible, menos nervioso y, francamente, bastante mas facil de operar.

Top comments (0)