Inicio / Artículos / Tablas de bandeja de salida e entrada: Diseño de webhooks que sobreviven a las fallas

Tablas de bandeja de salida e entrada: Diseño de webhooks que sobreviven a las fallas

Aprenda cómo el buzón de salida transaccional, un buzón de entrada idempotente, la diferimiento consciente del estado y las colas de cartas muertas hacen que la entrega de webhook sea fiable en AWS, Azure y GCP.

2740 palabras

Los webhooks parecen ser la integración más sencilla que existe: un lado realiza una solicitud HTTP POST y el otro la procesa. En la práctica, conllevan todos los riesgos de un sistema distribuido, ya que la red entre dos servicios puede perder solicitudes, agotar el tiempo de espera a mitad de camino, entregar el mismo contenido dos veces o reordenar los eventos. Si tratas un webhook como una llamada CRUD ordinaria, eventualmente perderás notificaciones, activarás efectos secundarios dos veces y terminarás con dos sistemas que no están de acuerdo sobre lo que ocurrió.

Esta guía explica en detalle un diseño que resiste esas condiciones. Verá por qué el enfoque ingenuo falla, cómo una bandeja de salida transaccional hace que los webhooks salientes sean fiables, cómo una bandeja de entrada idempotente permite reintentar los webhooks entrantes de forma segura, cómo lidiar con eventos que llegan en el orden incorrecto, cómo poner en cuarentena cargas útiles que nunca podrán tener éxito, y qué servicios gestionados en AWS, Azure y Google Cloud se adaptan a cada componente.

Por qué la implementación obvia pierde datos

Considere un backend SaaS que gestiona un cambio de estado significativo, como la entrega de un pedido o la activación de una suscripción. Un socio llama a su API para confirmar la acción, y ahora su servicio tiene dos tareas:

  1. Permitir la persistencia del nuevo estado, por ejemplo estableciendo el estado de la entidad en Active.
  2. Informar a un servicio posterior de que la entidad está lista, enviándole un webhook.

El código intuitivo escribe en la base de datos y, en la línea siguiente, envía la solicitud HTTP. Eso constituye una escritura dual: dos sistemas independientes se actualizan uno tras otro, sin que nada los vincule.

De ello se derivan directamente dos modos de fallo:

  • El proceso se interrumpe entre los dos pasos. La base de datos indica ahora que la entidad está activa, pero la solicitud nunca se envió. Sus registros son correctos, el servicio posterior no sabe nada y nadie se da cuenta hasta que un cliente se queja.
  • La solicitud se envía, pero luego la transacción falla. El servicio posterior ha recibido la indicación de que la entidad está activa, pero su base de datos ha revertido los cambios y sigue considerando que la operación falló.

Ni un orden determinado resuelve el problema. Si se realiza la llamada HTTP primero, se produce el segundo fallo; si se hace al final, ocurre el primero. La causa raíz es que un commit de base de datos y una llamada de red no pueden realizarse de forma atómica al mismo tiempo, por lo que cualquier interrupción o error en el proceso deja des sincronizados ambos lados.

Envío fiable de webhooks con un sistema de bandeja de salida transaccional

El patrón de bandeja de salida elimina la escritura doble al no realizar nunca la llamada HTTP desde la ruta de la solicitud. En su lugar, la intención de enviar el webhook se convierte en datos, y esos datos se escriben en la misma transacción de base de datos que el cambio empresarial. O ambos se confirman o ninguno lo hace.

Los cuatro pasos del flujo de la bandeja de salida

  1. Abre una transacción. La operación empresarial inicia una transacción normal de base de datos.
  • Escriba el estado y el evento juntos. Dentro de esa transacción, el servicio actualiza la tabla entities (por ejemplo, estableciendo el estado en Active) e inserta una fila en outbox_events que contiene exactamente el contenido que debe recibir el servicio siguiente en la cadena.
  • Confirmar. Una vez que se confirma la transacción, la base de datos registra de forma permanente tanto el nuevo estado como la promesa de anunciarlo.
  • Retransmitir el evento. Un trabajador en segundo plano separado, generalmente llamado retransmisor o publicador, busca repetidamente las filas de la bandeja de salida que aún no se han enviado. Para cada una de ellas realiza una solicitud HTTP POST y luego marca la fila como procesada.
  • La tabla outbox

    La tabla a continuación almacena una fila por cada notificación pendiente. aggregate_type y aggregate_id identifican qué objeto de negocio se refiere el evento, event_type indica qué ocurrió, payload contiene el cuerpo a entregar y processed_at permanece vacío hasta que el relay confirma la entrega. Consultar las filas donde processed_at es nulo proporciona al relay su lista de tareas. Tenga en cuenta que los comentarios incrustados utilizan un solo guion; en PostgreSQL se necesitan dos (--), así que corrija eso antes de ejecutar la consulta.

    CREATE TABLE outbox_events (
    id UUID PRIMARY KEY,
    aggregate_type VARCHAR(50), - e.g., 'Order' or 'User'
    aggregate_id UUID, - e.g., Entity ID
    event_type VARCHAR(100), - e.g., 'order.activated'
    payload JSONB NOT NULL, - The exact webhook payload
    created_at TIMESTAMP DEFAULT NOW(),
    processed_at TIMESTAMP - Null until successfully sent
    );
    

    Qué garantiza el buzón de salida y qué no

    Si el servidor se cae después del commit, no se pierde nada: la fila sigue estando en la tabla y el relay la encontrará en su próxima pasada. Si el endpoint de destino no está disponible, el relay simplemente vuelve a intentarlo, idealmente con un retroceso exponencial para no sobrecargar al receptor que tiene dificultades. El cambio de estado y la intención de notificar ya no pueden desviarse.

    La contrapartida es que la entrega se produce al menos una vez. Un relay puede enviar la solicitud con éxito y luego colapsar antes de actualizar processed_at; en ese caso, el mismo evento se envía nuevamente en la siguiente ejecución. Esto solo es aceptable si los receptores realizan la deduplicación, lo cual es exactamente lo que proporciona el patrón de bandeja de entrada del otro lado. Incluir el id de la fila de la bandeja de salida en la carga útil o en un encabezado brinda a los receptores una clave estable sobre la cual realizar la deduplicación. Si ejecutas varias instancias de relay, asegúrate de que dos procesos no puedan reclamar la misma fila al mismo tiempo; en PostgreSQL, seleccionar filas con FOR UPDATE SKIP LOCKED es una forma común de hacerlo.

    Recibir webhooks de manera segura con una bandeja de entrada idempotente

    Ahora cambia la perspectiva a los webhooks que tu servicio recibe de socios o sistemas ascendentes.

    Supongamos que su manejador tarda cinco segundos porque realiza cálculos intensivos o espera a que otro servicio libere un bloqueo. El cliente HTTP del remitente podría rendirse antes de que usted responda, concluir que nunca recibió el evento y enviarlo nuevamente. Ahora el mismo evento llega dos veces. Si su manejador envía un correo electrónico o crea un registro cada vez que se ejecuta, el cliente recibe dos correos y usted obtiene una fila duplicada.

    El patrón de bandeja de entrada separa la aceptación de un webhook de la acción que se realiza sobre él.

    Los cuatro pasos del flujo de la bandeja de entrada

    1. Recibir y verificar. Tan pronto como llega la solicitud, verifique su firma HMAC para asegurarse de que realmente proviene del socio y no fue falsificada ni modificada.
  • Almacene la carga útil sin procesar. Inserte el cuerpo JSON intacto en una tabla webhook_inbox, identificado con el propio identificador único de evento del socio y protegido por una restricción de unicidad en la base de datos.
  • Confirme de inmediato. Devuelva 200 OK de forma inmediata, antes de que se ejecute cualquier lógica empresarial.
  • Procese en segundo plano. Un proceso de fondo recoge las filas pendientes del buzón, verifica si cada evento ya ha sido manejado, lo omite en caso afirmativo y, de lo contrario, ejecuta la lógica empresarial y marca la fila como procesada.
  • La tabla del buzón

    Aquí, cada fila registra quién envió el evento (partner_name), el identificador del remitente (partner_event_id), la carga útil, si la firma fue verificada y un status que puede ser PENDING, PROCESSED o QUARANTINED. La línea importante es la restricción compuesta UNIQUE(partner_name, partner_event_id): es lo que convierte los duplicados en operaciones inofensivas sin efecto alguno. Al igual que en la tabla de envíos, los comentarios con un solo guion deben convertirse en -- para que PostgreSQL acepte la declaración.

    CREATE TABLE webhook_inbox (
      id UUID PRIMARY KEY,
      partner_name VARCHAR(50), - e.g., 'Stripe' or 'GitHub'
      partner_event_id VARCHAR(100), - The unique ID from the sender
      payload JSONB NOT NULL,
      signature_verified BOOLEAN,
      status VARCHAR(20), - 'PENDING', 'PROCESSED', 'QUARANTINED'
      received_at TIMESTAMP DEFAULT NOW(),
      processed_at TIMESTAMP,
      UNIQUE(partner_name, partner_event_id) - Prevents duplicate inserts
    );
    

    Por qué la restricción realiza el trabajo principal

    Dado que el manejador solo verifica, inserta y devuelve los datos, responde rápidamente y el remitente rara vez experimenta tiempos de espera excesivos desde un principio. Cuando el remitente vuelve a intentarlo, incluso diez veces seguidas, la restricción de unicidad permite que solo una inserción tenga éxito. Su manejador debe considerar el error de violación de unicidad resultante (o un resultado de ON CONFLICT DO NOTHING) como un éxito y seguir devolviendo 200 OK; de lo contrario, el remitente seguirá intentando procesar un evento que ya existe. Como solo existe una fila, el proceso ejecuta los efectos secundarios una sola vez.

    Hay dos detalles que es importante manejar correctamente. Primero, la deduplicación depende de que el socio proporcione un identificador de evento estable; la mayoría de los proveedores de webhook incluyen uno, pero confirme esto para cada integración. Segundo, un proceso puede colapsar después de realizar el efecto secundario pero antes de marcar la fila como procesada, por lo que, siempre que sea posible, ejecute el cambio de negocio y la actualización de estado en una sola transacción, y haga que los efectos secundarios externos también sean idempotentes. Para conocer más sobre cómo deduplicar solicitudes con claves, consulte las claves de idempotencia en los endpoints POST de Node.js.

    Manejo de eventos que llegan fuera de orden

    Incluso con los duplicados bajo control, no hay garantía de que los eventos lleguen en el orden en que se generaron. Su servicio podría recibir entity.completed antes que entity.started. Un manejador que aplique ciegamente cada evento intentará entonces mover una entidad directamente de draft a completed, lo que dañará su estado o provocará un error como 409 Conflict.

    Verificación de cada transición contra la máquina de estados

    La solución es dejar de tratar los eventos como comandos para modificar el estado y comenzar a considerarlos como transiciones propuestas que deben validarse. Esto a veces se describe como un motor de reconciliación de estados, al estilo del origen de eventos: el procesador compara el evento recibido con el estado actual de la entidad y decide si la transición es válida.

    El boceto a continuación muestra esa decisión. Si llega un evento de finalización mientras la entidad sigue siendo un borrador, el requisito previo aún no se ha cumplido, por lo que la función informa del evento como diferido en lugar de aplicarlo. Los comentarios mencionan dos formas de manejar la diferición: dejarla en la bandeja de entrada y volver a intentarlo más tarde, o registrar un estado proyectado y esperar el evento faltante. Un evento de inicio en una entidad borradora es una transición válida y se aplica. Considérelo como pseudocódigo: return status: 'DEFERRED'; no es JavaScript válido y debería ser return { status: 'DEFERRED' };; además, una implementación real también manejaría las demás combinaciones de evento y estado.

    function processWebhookEvent(event, currentEntityState) {
        if (event.type === 'entity.completed' && currentEntityState === 'draft') {
            // The 'started' event hasn't arrived yet!
            // We cannot transition from 'draft' directly to 'completed'.
    
            // Option A: Leave it in the inbox and retry in 5 minutes.
            // Option B: Store a "Projected State" and wait for the missing piece.
            return status: 'DEFERRED';
        }
    
        if (event.type === 'entity.started' && currentEntityState === 'draft') {
            return transitionTo('started');
        }
    }
    

    Diferición como bucle autoreparable

    Tome un pedido en el que el evento “enviado” llegue antes que el evento “pagado”. Aplicar inmediatamente el estado “enviado” pondría al pedido en un estado que su modelo no permite. Con un procesador consciente del estado, la secuencia se convierte en:

    1. Llega el estado “enviado”; el evaluador detecta que falta el pago y el evento se pospone.
    2. Llega el estado “pagado”, es válido y actualiza el pedido.
    3. Se vuelve a intentar el evento “enviado” pospuesto; ahora encuentra que su prerequisito está satisfecho y se aplica.

    Los eventos diferidos pueden permanecer en una cola de intentos dedicada, como Amazon SQS o una cola respaldada por Redis, y un trabajador en segundo plano los intenta nuevamente periódicamente. El resultado es un flujo de trabajo que rechaza las transiciones inválidas pero que, con el tiempo, converge en el estado correcto sin descartar ningún evento. Sin embargo, hay que establecer un límite para determinar cuánto tiempo puede permanecer diferido un evento: si el requisito previo nunca llega, dicho evento debería considerarse finalmente como un fallo en lugar de intentarse una y otra vez indefinidamente, lo cual nos lleva a la siguiente sección.

    Aislar los problemas con intentos repetidos y una cola de cartas muertas

    Algunos eventos nunca tendrán éxito, sin importar cuántas veces se intenten de nuevo: un payload mal formado o una referencia a un ID que no existe en la base de datos. Estos se denominan píldoras venenosas. Un procesador ingenuo los intentará una y otra vez indefinidamente, y si la cola se procesa en orden, un mensaje defectuoso puede bloquear todos los eventos válidos que vengan después.

    La defensa estándar es una política de intentos restringida con retrasos cada vez mayores, seguida por una cola de mensajes defectuosos (DLQ). Un cronograma típico se ve así:

    1. El primer intento falla; esperar un minuto.
    2. El segundo intento falla; esperar cinco minutos.
    3. El tercer intento falla; esperar quince minutos.
    4. El cuarto intento falla; mover el evento a la DLQ.

    El DLQ puede ser una tabla en su propia base de datos o una función de una cola gestionada. Lo importante es lo que ocurre a continuación: los eventos del DLQ deben aparecer en una vista administrativa interna y generar una alerta de alta prioridad, ya que cada uno representa datos que su sistema no pudo procesar. Un ingeniero investiga, corrige el error de mapeo o los datos defectuosos y luego vuelve a reproducir el evento para que fluya por la ruta de procesamiento normal. Diseñe esa acción de reproducción desde el principio; sin ella, recuperarse de un DLQ se convierte en una edición manual de la base de datos bajo presión.

    Asignación del diseño a AWS, Azure y Google Cloud

    La bandeja de salida y la bandeja de entrada se encuentran en su base de datos relacional, pero los componentes asociados (ingreso, colas, procesadores, DLQs) se adaptan bien a los servicios en la nube gestionados, lo que reduce en gran medida la carga operativa. La estructura es la misma en cada proveedor; solo cambian los nombres de los productos.

    AWS

    • Ingreso: Amazon API Gateway acepta los webhooks entrantes, y un autorizador Lambda verifica la firma HMAC antes de que la solicitud llegue al backend.
    • Banco de datos: Amazon Aurora PostgreSQL almacena las tablas empresariales junto con webhook_inbox y outbox_events, de modo que se aplican las garantías transaccionales.
    • Colas y DLQ: Una cola estándar de SQS gestiona el procesamiento asíncrono, y una cola de cartas muertas de SQS configurada recibe los mensajes una vez que superan el límite máximo de recepción. Las colas estándar son de tipo al menos una vez y no preservan el orden, lo cual es otra razón por la que son importantes las verificaciones de idempotencia y estado mencionadas anteriormente.
  • Trabajadores: Las funciones Lambda, activadas por SQS, procesan los eventos entrantes. El relé de salida funciona como una Lambda programada o una tarea ECS Fargate que consulta Aurora cada pocos segundos, envía los eventos pendientes y establece el valor de processed_at.
  • Azure

    • Ingreso: Azure API Management recibe los webhooks, valida las firmas y reenvía las solicitudes al backend.
    • Banco de datos: Azure Database for PostgreSQL Flexible Server almacena el estado de la aplicación, además de las tablas de entrada y salida.
    • Colas y DLQ: Azure Service Bus actúa como intermediario de los mensajes e incluye una función integrada para mensajes defectuosos, que mueve automáticamente un mensaje a un lugar aparte después de un número configurado de intentos de entrega.
    • Trabajadores: Azure Functions con desencadenantes de Service Bus procesan los datos de la bandeja de entrada. El relé de la bandeja de salida funciona como un bucle en segundo plano en Azure Container Apps, o como un Kubernetes CronJob si se utiliza AKS; consulta PostgreSQL en busca de eventos no enviados y los entrega mediante HTTP.

    Google Cloud

    • Ingreso: Google Cloud API Gateway se encarga de los webhooks HTTP entrantes y de la autenticación.
    • Banco de datos: Cloud SQL para PostgreSQL almacena los datos relacionales, incluidas ambas tablas.
    • Colas y DLQ: Pub/Sub dirige los mensajes de forma asíncrona. La suscripción principal procesa los eventos, y un tema de cartas muertas captura los mensajes que aún no han sido reconocidos después del número máximo configurado de intentos de entrega.
    • Trabajadores: Los servicios Cloud Run, capaces de reducirse a cero instancias entre picos de carga, reciben entregas push mediante Pub/Sub para procesar los mensajes entrantes. El relé de mensajes salientes es ya sea un trabajo Cloud Run o un servicio Cloud Run invocado según un horario por Cloud Scheduler, el cual consulta Cloud SQL y envía los eventos pendientes.

    Para conocer más patrones relacionados con la conexión de servicios, como OAuth y llamadas a API resilientes, consulte seis patrones de integración para conectar servicios Node.js.

    Puntos clave

    • Un sistema de webhook fiable es un pipeline de procesamiento de eventos, no un par de puntos finales HTTP.
    • Nunca actualice la base de datos y llame a un servicio remoto como dos pasos independientes; escriba una fila en la lista de mensajes salientes dentro de la misma transacción y deje que un relé la entregue.
    • El buzón de salida garantiza una entrega al menos una vez, por lo que cada receptor debe eliminar las duplicadas.
    • En el lado receptor, se verifica el contenido, se almacena aplicando una restricción de unicidad en el ID del evento del remitente, se confirma inmediatamente y el trabajo real se realiza en un proceso de fondo.
    • Se valida cada evento contra la máquina de estados y se posponen aquellos que carecen de requisitos previos, estableciéndose un límite en el tiempo de espera.
    • Se limitan los intentos de reenvío, se dirigen los fallos persistentes a un buzón de correos no entregados con notificaciones, y se hace que la reproducción sea una operación de primera categoría.
    • Las colas gestionadas como SQS, Service Bus y Pub/Sub proporcionan mecanismos de reintentos y manejo de correos no entregados, mientras que las tablas de base de datos mantienen las garantías importantes.

    Lecturas relacionadas