Un webhook puede funcionar durante semanas y fallar justo cuando un proveedor tarda un poco más en responder. El cliente ve un timeout y reintenta; nuestro endpoint quizá ya guardó el evento y empezó a enviar una notificación. Si no hemos diseñado ese caso, una sola acción termina duplicada.
En proyectos Python prefiero separar dos preguntas: ¿recibimos el evento? y ¿terminamos su procesamiento? FastAPI hace sencilla la primera parte, pero la segunda necesita un contrato explícito. Este patrón pequeño ayuda a construir APIs más predecibles sin añadir una plataforma enorme desde el primer día.
El timeout no significa que el webhook falló
Un timeout solo dice que el cliente no recibió una respuesta a tiempo. No prueba que el servidor no haya hecho nada. La petición pudo llegar, pasar la validación y quedar en una cola mientras la conexión se cerraba.
Por eso un endpoint de webhook no debería hacer todo el trabajo antes de responder. Validar la firma, comprobar el esquema y registrar una clave de idempotencia suele ser suficiente para devolver 202 Accepted. El procesamiento pesado puede continuar en una tarea o worker.
La secuencia queda así:
- El proveedor envía el evento con un identificador único.
- FastAPI valida autenticación, formato y tamaño.
- La aplicación registra el identificador si todavía no existe.
- El endpoint responde rápido con
202. - Un worker procesa el evento y deja un resultado observable.
Es una diferencia facil de pasar por alto: el código HTTP describe la aceptación, no la entrega final del email ni el estado del negocio.
Un contrato pequeño para el endpoint
Un esquema claro evita que cada integración invente su propia interpretación. Este ejemplo no implementa un sistema de colas, pero muestra qué debe quedar definido en la frontera:
from datetime import datetime
from pydantic import BaseModel, Field
class WebhookEvent(BaseModel):
event_id: str = Field(min_length=1, max_length=120)
event_type: str
occurred_at: datetime
payload: dict
El handler puede devolver un resultado mínimo:
from fastapi import FastAPI, status
app = FastAPI()
@app.post("/hooks/provider", status_code=status.HTTP_202_ACCEPTED)
async def receive_hook(event: WebhookEvent):
accepted = await event_store.insert_if_new(event.event_id, event.model_dump())
return {"accepted": True, "duplicate": not accepted}
La operación insert_if_new debe ser atómica, respaldada por un índice único sobre event_id. Un diccionario en memoria puede servir para una demo, pero no para varias réplicas. Si dos peticiones llegan casi al mismo tiempo, ambas deben observar el mismo contrato.
También conviene documentar qué ocurre con un evento repetido. Devolver 202 con duplicate: true permite que el proveedor deje de reintentar, mientras el sistema mantiene una métrica separada para investigar la causa.
Idempotencia antes de reintentar
El reintento es sano cuando el efecto también lo es. Antes de configurar tres reintentos exponenciales, decide cuál es la unidad que no puede duplicarse: un email enviado, una factura creada o una transición de estado.
Para una notificación, guardaría una clave compuesta parecida a event_id + template_version + recipient. El worker consulta esa clave dentro de una transacción y solo entonces crea la tarea de envío. Si el proveedor vuelve a llamar, la API responde con el mismo resultado lógico, aunque el cuerpo HTTP tenga pequeñas diferencias.
No hay que confundir esto con ignorar errores. Si el primer procesamiento quedó a medias, el registro debe tener un estado como pending, sent o failed, además de un contador de intentos. Un estado unknown es útil cuando el worker perdió la conexión justo despues de pedir el envío y no puede afirmar si el proveedor aceptó la operación.
Para revisar la parte de notificaciones, me gusta mantener separadas las emails de prueba que no rompen tus métricas. La automatización de pruebas no debe modificar datos de producción ni depender de una bandeja personal.
Pruebas con un buzón aislado
Una prueba de integración útil provoca un evento, espera el recibo y comprueba el identificador de correlación. Un buzón temporal como tempmailso puede aislar ese flujo cuando la prueba necesita observar una notificación real sin tocar una cuenta del equipo.
El test debe usar un run_id nuevo y filtrar mensajes por tiempo, destinatario y una cabecera de correlación. Buscar solo por asunto es una trampa: el mensaje de una ejecución anterior puede parecer válido. En algunas notas de QA aparece la cadena temp mailid, pero no debería formar parte de la lógica de búsqueda.
Una aserción conceptual sería:
assert response.status_code == 202
assert response.json()["accepted"] is True
message = await inbox.wait_for_header("X-Run-Id", run_id, timeout=15)
assert message.template == "welcome-v3"
assert message.event_id == event.event_id
Cuando la prueba falla, guarda el run_id, el event_id, la hora de envío, los estados observados y el motivo de cada mensaje descartado. Eso produce una evidencia mas pequeña y util que un log completo. Si usas contratos para automatizaciones, también puede servir revisar una automatización con contratos mínimos.
Checklist de implementación
- Validar firma, tamaño y esquema antes de aceptar el evento.
- Responder
202solo cuando el evento quedó registrado de forma durable. - Crear una restricción única para la clave de idempotencia.
- Separar recepción, procesamiento y envío de notificaciones.
- Medir duplicados, latencia, reintentos y estados
unknown. - Probar un timeout del cliente y verificar que no aparece un segundo email.
- Limpiar los buzones y artefactos de prueba despues de cada ejecución.
Un detalle mas: el timeout del cliente debe estar cubierto en las pruebas, no solo el timeout del worker. Son fallos distintos y requieren diagnósticos distintos. Si el endpoint acepta dos veces, el problema es de idempotencia; si acepta una vez pero el email no sale, hay que mirar el worker o el proveedor.
Preguntas rápidas
¿Debo devolver 200 cuando el trabajo aún no termina?
No necesariamente. 202 Accepted comunica mejor que el evento fue aceptado para procesamiento posterior. Elige 200 solo si la operación terminó y puedes afirmar su resultado.
¿Un retry siempre debe crear un nuevo registro?
No. El reintento puede crear un intento de procesamiento, pero el evento de negocio debe conservar su identidad. Separar ambos identificadores hace el historial mucho mas claro.
¿Qué pasa si el proveedor no envía un identificador?
Genera una huella con campos estables solo como último recurso y documenta la limitación. Es mejor pedir un identificador oficial: deduplicar por asunto, fecha o contenido parcial falla facilmente con eventos legítimos parecidos.
El objetivo no es evitar todos los timeouts. Es hacer que un timeout sea una señal diagnostica, no una invitación a duplicar efectos. Con un contrato pequeño, una clave idempotente y una prueba de integración aislada, los webhooks de FastAPI se vuelven bastante menos misteriosos.
Top comments (0)