DEV Community

Olivia Cheng
Olivia Cheng

Posted on

FastAPI: clasifica fallos de email sin reintentar a ciegas

En una API que envía emails, el reintento suele parecer la solución más fácil: si la entrega falla, esperamos un poco y repetimos. El problema aparece cuando tratamos igual un timeout, una dirección inválida y una bandeja temporal que todavía no ha recibido el mensaje. El sistema insiste, aumenta el ruido y al final nadie sabe qué ocurrió realmente.

En proyectos con FastAPI me resulta más útil clasificar el fallo antes de decidir la siguiente acción. Así una automatización puede reintentar un problema transitorio, detenerse ante un error permanente y dejar el caso en revisión cuando falta información. Es un cambio pequeño, pero hace que las APIs sean más previsibles.

El error no siempre merece otro intento

Hay tres familias que conviene separar desde el principio:

  • Transitorio: timeout, conexión reiniciada o respuesta 5xx del proveedor. Puede merecer un reintento con límite.
  • Permanente: formato inválido, dominio inexistente o credenciales rechazadas. Repetirlo no lo arregla.
  • Indeterminado: el proveedor aceptó la solicitud, pero la respuesta se perdió. Aquí hace falta una consulta de estado o una clave de idempotencia.

El objetivo no es adivinar el futuro. Es evitar que el código convierta cualquier excepción en una orden de “hazlo otra vez”. Una función de dominio puede devolver una decisión clara: retry, stop o review.

Clasifica antes de reintentar

Podemos representar la decisión con un modelo pequeño. No hace falta construir un sistema de reglas enorme para empezar:

from enum import StrEnum
from pydantic import BaseModel


class NextAction(StrEnum):
    RETRY = "retry"
    STOP = "stop"
    REVIEW = "review"


class DeliveryDecision(BaseModel):
    action: NextAction
    reason: str
    retry_after_seconds: int | None = None


def decide_delivery(status_code: int | None, attempts: int) -> DeliveryDecision:
    if attempts >= 3:
        return DeliveryDecision(action=NextAction.REVIEW, reason="límite alcanzado")
    if status_code is None or status_code >= 500:
        return DeliveryDecision(
            action=NextAction.RETRY,
            reason="fallo transitorio",
            retry_after_seconds=2 ** attempts,
        )
    if status_code in {400, 401, 403, 422}:
        return DeliveryDecision(action=NextAction.STOP, reason="fallo permanente")
    return DeliveryDecision(action=NextAction.REVIEW, reason="respuesta no clasificada")
Enter fullscreen mode Exit fullscreen mode

La regla importante está en el orden. Primero se comprueba el límite de intentos y después el tipo de respuesta. Si no, un proveedor que devuelve 503 puede generar una cadena de reintentos infinita. El backoff es sencillo a propósito; luego se le puede añadir un poco de jitter para que varios workers no vuelvan al mismo tiempo.

Un endpoint pequeño en FastAPI

La API no tiene que conocer todos los detalles del proveedor. Puede recibir el resultado normalizado de un worker y exponer la decisión para que el siguiente paso sea explícito:

from fastapi import FastAPI

app = FastAPI()


@app.post("/deliveries/{delivery_id}/decision", response_model=DeliveryDecision)
def delivery_decision(delivery_id: str, status_code: int | None = None, attempts: int = 0):
    # En producción, delivery_id debería resolver el estado persistido.
    return decide_delivery(status_code, attempts)
Enter fullscreen mode Exit fullscreen mode

En producción validaría que delivery_id existe, limitaría attempts recibido por el cliente y guardaría la decisión junto al evento original. El endpoint es un ejemplo del contrato, no una invitación a confiar en datos enviados sin autenticación.

También ayuda separar la entrega de la observación. Un aviso de email con contexto real necesita decir qué evento falló, qué intento era y qué acción se tomó. La interfaz que consume la API no debería recibir solo “error 500”.

Registra la decisión, no solo la excepción

Para depurar una automatización, guardaría al menos estos campos:

delivery_id, event_id, attempt, provider_status,
classification, next_action, retry_after, created_at
Enter fullscreen mode Exit fullscreen mode

No conviene registrar el contenido completo del mensaje ni tokens de acceso. Un identificador de evento y una razón corta suelen bastar. Si el test usa un temp org mail o un dummy e mail, la fixture debe quedar aislada del correo real y tener una caducidad conocida.

En esta parte, el polling también necesita límites sanos: duración máxima, intervalo y condición de salida. Una espera que no sabe cuándo terminar es una fuga de tiempo escondida dentro del test.

Pruebas para automatizaciones previsibles

Las pruebas unitarias deben cubrir la tabla de decisiones, no únicamente el camino feliz:

def test_timeout_retries_with_backoff():
    result = decide_delivery(None, attempts=1)
    assert result.action == NextAction.RETRY
    assert result.retry_after_seconds == 2


def test_invalid_address_stops():
    result = decide_delivery(422, attempts=0)
    assert result.action == NextAction.STOP


def test_max_attempts_goes_to_review():
    result = decide_delivery(503, attempts=3)
    assert result.action == NextAction.REVIEW
Enter fullscreen mode Exit fullscreen mode

Para una prueba de integración, simularía una respuesta 503 seguida de 202 y comprobaría que solo hay dos llamadas. Después probaría un 422 y verificaría que no aparece una segunda llamada. Es facil olvidar este último caso cuando todo el entorno de pruebas responde demasiado bien.

Si el flujo tiene una interfaz web, vale la pena conectar el estado backend con mensajes comprensibles. Un estado “revisar” es mejor que un spinner eterno; este tipo de detalle también aparece al diseñar avisos de email con contexto real. Para comprobar la bandeja, puedes aplicar polling de inbox con límites sanos.

Q&A: dudas habituales

¿Debo reintentar todos los errores 5xx?

No necesariamente. Un 5xx suele ser transitorio, pero el proveedor puede estar rechazando una operación por saturación o por una cuota. Usa un máximo de intentos, backoff y una ruta de revisión. Si existe un encabezado Retry-After, úsalo dentro de un límite razonable.

¿Qué hago si no recibo respuesta?

Trátalo como indeterminado. Antes de enviar otra vez, consulta el estado con un identificador idempotente. Duplicar un email porque se perdió la respuesta es peor que tardar unos segundos en confirmar.

Checklist de implementación

  • Define las categorías transitorio, permanente e indeterminado.
  • Pon un límite de intentos fuera del proveedor.
  • Devuelve una acción explícita: reintentar, detener o revisar.
  • Registra evento, intento y decisión sin guardar datos innecesarios.
  • Añade backoff y, cuando aplique, Retry-After.
  • Prueba una secuencia 5xx → éxito y otra con error permanente.
  • Establece un timeout para cada polling y para el flujo completo.

La automatización de email no necesita más reintentos, sino mejores decisiones. Cuando FastAPI expone un contrato pequeño y cada fallo deja evidencia suficiente, los workers pueden recuperarse sin ocultar los casos que necesitan atención humana.

Top comments (1)

Collapse
 
aiapikey profile image
于侃 •

Three families is the right split, and the indeterminate bucket is where implementations quietly break. Resolving it by "query status" needs a correlation key that survived the lost response, and the only one you have is the idempotency key captured before the send. Without it, status lookup degrades into matching recipient plus timestamp, which is ambiguous the moment two messages to the same address go out inside a second.

Worth also storing the class rather than only acting on it. Indeterminate over total is your provider's response-loss rate, and it usually moves before the timeout rate does - so it is the earlier warning of a provider going bad.