FastAPI: pon presupuesto a emails lentos

# python# fastapi# backend# api
FastAPI: pon presupuesto a emails lentosSilviu Technology

Guia practica para acotar espera, reintentos y visibilidad en emails async con FastAPI sin volver fragiles tus pruebas.

En muchos backends con FastAPI el email se manda fuera del request y eso está bien. El problema aparece cuando nadie define cuánto vale la pena esperar, cuándo reintentar y en qué momento una prueba debe fallar con una señal clara. Sin ese presupuesto, soporte mira logs, QA repite escenarios y producto siente que el flujo "a veces va" y a veces no. No es un bug raro; es una falta de contrato, y pasa bastnate.

Yo intento resolverlo con una idea simple: cada email asíncrono necesita un estado visible y una ventana de espera explícita. Si un signup debe confirmar correo en 20 segundos, dilo en código, en métricas y en pruebas. Si a veces tarda 2 minutos por diseño, dilo tambien. Lo importante es dejar de tratar la espera como algo infinito.

El error comun: esperar sin presupuesto

Lo más común no es fallar al enviar, sino fallar al decidir cuándo algo ya tardó demasiado.

Cuando el backend solo responde 202 Accepted, todo el mundo completa el resto con su imaginación:

  • el frontend deja un spinner eterno
  • QA sube el timeout "por si acaso"
  • soporte pide repetir el intento
  • el equipo backend no sabe si el problema es cola, proveedor o plantilla

Ese patrón además vuelve frágiles las pruebas con email desechable. Un test no necesita esperar para siempre hasta ver un mensaje; necesita saber qué ventana es razonable para ese flujo. Si no, el caso termina validando paciencia, no software.

Un contrato pequeno para estado y tiempo

Mi versión mínima del contrato tiene dos partes:

  1. estado observable del job
  2. presupuesto de espera por tipo de correo

Los estados suelen bastar con queued, sending, sent y failed. El presupuesto de espera puede ser algo como expected_within_seconds. Esto cambia la conversación del equipo porque ya no preguntas "¿salió el correo?", sino "¿salió dentro del presupuesto?".

También ayuda a separar incidentes reales de ruido. Si un correo de bienvenida tiene objetivo de 15 segundos y hoy tarda 18, probablemente no sea grave. Si tarda 90, sí quieres alertar. Esa diferencia parece obvia, pero cuando no queda escrita nadie la recuerda igual, y ahi nacen tickets medio confusos.

Ejemplo con FastAPI y polling corto

Este ejemplo es pequeño, pero enseña bien la idea:

from enum import StrEnum
from fastapi import FastAPI, BackgroundTasks, HTTPException
from pydantic import BaseModel
from time import time
from uuid import uuid4

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


class EmailState(StrEnum):
    QUEUED = "queued"
    SENDING = "sending"
    SENT = "sent"
    FAILED = "failed"


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


def send_job(job_id: str) -> None:
    job = jobs[job_id]
    job["state"] = EmailState.SENDING
    try:
        deliver_email(job["to"], job["template"])
        job["state"] = EmailState.SENT
    except Exception as exc:
        job["state"] = EmailState.FAILED
        job["error"] = str(exc)
    finally:
        job["updated_at"] = int(time())


@app.post("/email-jobs")
def create_email_job(payload: EmailRequest, tasks: BackgroundTasks):
    job_id = str(uuid4())
    jobs[job_id] = {
        "to": payload.to,
        "template": payload.template,
        "state": EmailState.QUEUED,
        "expected_within_seconds": 20,
        "updated_at": int(time()),
    }
    tasks.add_task(send_job, job_id)
    return {"job_id": job_id, **jobs[job_id]}


@app.get("/email-jobs/{job_id}")
def get_email_job(job_id: str):
    job = jobs.get(job_id)
    if not job:
        raise HTTPException(status_code=404, detail="job not found")
    return job
Enter fullscreen mode Exit fullscreen mode

Con eso ya puedes hacer polling corto desde UI o desde una prueba. Yo suelo limitarlo a 20 o 30 segundos con pasos de 2 segundos. Si no llegó en esa ventana, la prueba falla con evidencia útil y no se queda colgada media vida. Parece una mejora chica, pero quita mucho desgaste.

Donde usar email desechable sin sobreprobar

Aquí veo otro error comun: querer verificar inbox real en todos los escenarios. No hace falta. Para la mayoría de pruebas de API basta validar transición de estado, timestamps y payload esperado. Deja las comprobaciones de inbox real para unas pocas pruebas end-to-end.

Ese reparto baja flakes y además ayuda a leer fallos. Si un caso usa textos internos como fake e mail com o dummy e mail para nombrar escenarios, está bien mientras esos marcadores no sustituyan la telemetría real. La verdad operativa debe seguir en tu job store, no en notas sueltas o screenshots.

Me gusta conectar este patrón con otras piezas del stack. Si ya tienes senales claras cuando cambia el estado en procesos operativos, aplicar esa misma disciplina a correo suele salir natural. Y si antes trabajaste en probar emails sin romper tu entorno, el siguiente paso lógico es acotar cuánto esperas y qué evidencia guardas cuando no llega.

Según Google, definir SLOs y señales observables reduce el tiempo perdido en diagnósticos porque el equipo deja de adivinar y empieza a comparar contra objetivos explícitos source. No arregla entregas lentas por si solo, pero si vuelve la conversación mucho más util.

Checklist para bajar soporte y flakes

Esto es lo que intento dejar listo desde el primer PR:

  • un job_id visible desde la respuesta inicial
  • un expected_within_seconds por flujo
  • timestamps de última transición
  • error legible para soporte y QA
  • polling corto con fin claro
  • una sola prueba end-to-end por camino crítico, no diez

Si puedes medir percentiles mejor aún. El benchmark State of DevOps suele insistir en ciclos de feedback cortos porque reducen retrabajo y hacen más rápido el diagnóstico source. En correo asíncrono aplica igual, aunque a veces se nos olvida.

Preguntas rapidas

¿FastAPI BackgroundTasks alcanza?

Para cargas moderadas, sí. Para flujos críticos o mucho volumen, mejor mover el envío a un worker separado. El contrato puede quedarse igual y eso simplifica bastante la migración.

¿Cuánto debería esperar una prueba?

Lo mínimo que cubra tu comportamiento normal más un margen pequeño. Si el promedio es 4 segundos, no pongas 3. Pero tampoco 180 "por si acaso". Ese tipo de timeout gigante suele esconder problemas, no resolverlos.

¿Qué mejora primero?

Primero la claridad. Cuando estado, tiempo esperado y evidencia quedan visibles, backend, soporte y QA discuten menos y arreglan antes. No suena glamoroso, pero funciona muy bien en equipos reales.