Estudio de caso arquitectónico · biblioteca de código abierto

Entrega confiable de webhooks en sistemas distribuidos

Una plataforma de comercio electrónico necesita comunicar eventos de pedidos incluso cuando los destinatarios presentan timeout, errores HTTP, indisponibilidad o reciben la misma entrega más de una vez.

El escenario de comercio electrónico es ilustrativo. Las garantías descritas corresponden a la API y a las pruebas de la versión 1.0.0; no representan adopción, benchmark ni operación real en producción.

Contexto y problema

Un POST directo no preserva la intención cuando la red interrumpe la conversación.

Después de registrar un pedido, enviar HTTP una sola vez mezcla la decisión de negocio con una dependencia remota. Un timeout no revela si el destinatario procesó la solicitud; las respuestas 5xx pueden ser temporales; un 4xx normalmente exige corrección, no repetición infinita.

Si el proceso se interrumpe después de que el destinatario acepta el mensaje y antes de persistir el éxito, un intento posterior puede duplicar el evento. La solución necesita hacer observables la cola, los intentos, la propiedad temporal del trabajo y los estados finales, sin transformar la incertidumbre distribuida en una promesa de exactly-once.

Requisitos arquitectónicos

La confiabilidad es un conjunto de contratos, no solo una política de reintentos.

01

Confiabilidad

Con un store durable y conforme, el trabajo aceptado puede sobrevivir al proceso y reanudarse con semántica at-least-once.

02

Idempotencia

Los ID estables hacen que el enqueue sea idempotente; el destinatario todavía debe evitar efectos duplicados.

03

Escalabilidad

La concurrencia limitada evita claims por encima de la capacidad; los leases coordinan workers que comparten el store.

04

Seguridad

HTTPS es el valor predeterminado; HMAC-SHA256 autentica el envelope cuando se registran el signer y el secret provider.

05

Observabilidad

Logs estructurados, ActivitySource y Meter exponen cola, intentos, resultados y duración.

06

Recuperación

Un lease vencido permite reclaim; el reintento persiste el próximo instante; los estados terminales dejan de ser claimable.

At-least-onceIdempotenciaConcurrencia limitadaSeguridadObservabilidadCapacidad de evolución

Arquitectura de la solución

Separar intención, coordinación, transporte y política hace explícita cada garantía.

Flujo de ReliableWebhooks: la aplicación coloca el mensaje en el store, el dispatcher obtiene un lease, el transporte lo envía al destinatario y persiste éxito, reintento, falla permanente o dead letter, con telemetría.
El core define el protocolo. La durabilidad real depende del IWebhookDeliveryStore proporcionado por la aplicación, y la idempotencia de los efectos depende del destinatario.
  1. 1. EnqueueIWebhookEnqueueService valida límites y utiliza el ID estable.
  2. 2. PersistenciaIWebhookDeliveryStore guarda mensaje, estado y programación.
  3. 3. ClaimWebhookDispatcher reclama solo trabajo vencido dentro de la capacidad.
  4. 4. LeaseEl token y el vencimiento identifican al owner activo y rechazan escrituras obsoletas.
  5. 5. TransporteIWebhookDeliveryTransport representa un intento HTTP.
  6. 6. TransiciónSe persisten el éxito, el reintento, la falla permanente o el dead letter.

Enqueue e ID estable

El servicio público valida los límites configurados antes de persistir. El ID identifica el par evento–suscriptor; volver a colocarlo en cola debe devolver el registro existente sin reemplazar payload, destino ni estado.

Implementación del enqueue ↗

Store como puerto

IWebhookDeliveryStore aísla la tecnología de persistencia, pero exige claim atómico, renovación, rechazo del owner obsoleto y transiciones terminales. El paquete no incluye un adapter durable.

Contrato de persistencia ↗

Dispatcher y leases

El dispatcher ocupa solo los slots disponibles, renueva el lease durante el intento y cancela el trabajo cuando pierde ownership. Durante el shutdown, detiene los nuevos claims y espera el grace period.

Código del dispatcher ↗

Transporte reemplazable

Una llamada a SendAsync es un intento. El transporte HTTP clasifica 2xx como éxito; 408, 425, 429 y 5xx como retryable; los demás estados son permanentes.

Clasificador predeterminado ↗

Backoff con jitter

La política predeterminada utiliza backoff exponencial limitado y jitter positivo. Un Retry-After válido puede posponer más allá del límite local; al alcanzar el máximo de intentos, el elemento pasa a dead letter.

Política predeterminada ↗

HMAC y telemetría

El signer HMAC-SHA256 cubre timestamp, ID, tipo de evento, content type y los bytes exactos del payload. La instrumentación publica nombres estables de traces y métricas sin incluir payload, firma ni destino.

Signer HMAC ↗

API pública

La aplicación registra la persistencia, configura el dispatcher y coloca en cola un mensaje inmutable.

// Producción: la aplicación proporciona un store durable y conforme.
services.AddSingleton<IWebhookDeliveryStore, MyDurableWebhookStore>();

ReliableWebhooksBuilder webhooks = services.AddReliableWebhooks(options =>
{
    options.Dispatcher.MaxConcurrency = 8;
    options.Dispatcher.LeaseDuration = TimeSpan.FromMinutes(2);
    options.Retry = new WebhookRetryPolicyOptions { MaxAttempts = 5 };
});
webhooks.AddHostedDispatcher();

WebhookMessage message = new(
    id: $"{orderCreated.EventId}:{subscriber.Id}",
    eventType: "order.created",
    destination: subscriber.WebhookUri,
    payload: JsonSerializer.SerializeToUtf8Bytes(orderCreated),
    contentType: "application/json");

await webhookEnqueueService.EnqueueAsync(message, cancellationToken);

AddHostedDispatcher() es opt-in. La aplicación también puede resolver WebhookDispatcher y controlar directamente su ciclo de vida.

Escenarios de ejecución

Las garantías aparecen en las rutas de falla, no solo en el happy path.

Entrega exitosa

El dispatcher obtiene un lease, el transporte recibe 2xx y el store ejecuta MarkSucceededAsync. Succeeded es terminal y no vuelve a los claims normales.

Garantía: una transición de éxito para el owner actual; todavía son posibles los duplicados si el endpoint aceptó y falló la persistencia del éxito.

Timeout seguido de reintento

El transporte devuelve una falla retryable. La política calcula el próximo instante con backoff/jitter y el store ejecuta ScheduleRetryAsync, libera el lease y preserva el contador.

Garantía: un nuevo intento después de la programación cuando el store es conforme; un timeout no demuestra que el destinatario no haya procesado.

Falla permanente

Un estado como 400 se clasifica como permanente de forma predeterminada. El dispatcher llama a MarkPermanentlyFailedAsync; el elemento no se repite automáticamente.

Garantía: estado terminal rastreable. Las fallas retryable agotadas terminan en DeadLettered, también sin replay automático.

Worker reiniciado

Con un store durable, el trabajo pendiente permanece disponible y un lease abandonado puede reclamarse después de vencer. El token anterior no puede escribir una transición tardía.

Límite: con InMemoryWebhookDeliveryStore, el reinicio pierde la cola; no existe recuperación durable en este modo.

Decisiones y trade-offs

La biblioteca controla el protocolo; la aplicación conserva las decisiones dependientes del dominio y de la infraestructura.

At-least-once, no exactly-once

Reenviar después de un resultado remoto incierto preserva la posibilidad de entrega, pero acepta duplicados. Exactly-once sobre HTTP requeriría una coordinación transaccional que la biblioteca y el destinatario no comparten.

Leases, no ejecución descoordinada

El ownership temporal permite escalar workers y recuperar trabajo abandonado. A cambio, el store necesita claim atómico, reloj autoritativo y compare-and-set del token.

Reintentos con backoff

Espaciar los intentos reduce la presión sobre un destinatario no disponible; el jitter reduce la sincronización entre workers. La latencia de recuperación aumenta y cada política debe reflejar el contrato remoto.

Persistencia extensible

No acoplar el core a una base de datos u ORM mantiene reutilizable la biblioteca. El costo es explícito: la aplicación implementa y opera un adapter durable y conforme para producción.

Idempotencia en el destinatario

El ID estable ayuda a reconocer duplicados, pero la biblioteca no controla el efecto remoto. El receiver elige una clave autenticada adecuada y persiste su propia decisión idempotente.

Puertos separados

Store, transporte, clasificador, retry, signer y providers pueden evolucionar de forma aislada. La composición gana flexibilidad, pero las implementaciones personalizadas deben preservar los contratos documentados.

División de responsabilidades entre ReliableWebhooks y la aplicación consumidora
Responsabilidad de la bibliotecaResponsabilidad de la aplicación
Protocolo de enqueue, claim, lease, intento y transición.Proporcionar persistencia durable y atómica adecuada para el entorno de producción.
Transporte HTTP predeterminado, clasificación y reintentos configurables.Elegir política, límites, cuotas, operación de dead letters y eventual replay.
Signer HMAC-SHA256 y hooks para secret/header providers.Administrar secrets, rotación, autorización de destinos y verificación en el receiver.
Logs, traces y métricas con nombres públicos estables.Recolectar, exportar, alertar, definir SLO y proteger los datos persistidos.
ID estable y enqueue idempotente en el contrato del store.Garantizar la idempotencia de los efectos de negocio en el destinatario.

Limitaciones y evolución

La confiabilidad solo existe hasta el límite que la implementación realmente sostiene.

La biblioteca no garantiza exactly-once, una transacción distribuida con el receiver, idempotencia del lado remoto, replay automático de dead letters ni una política de rotación de secrets. El destinatario debe tolerar mensajes duplicados.

InMemoryWebhookDeliveryStore es local al proceso y no es durable. El paquete no ofrece un store de producción: la aplicación debe implementar un IWebhookDeliveryStore conforme, incluidos atomicidad, lease, reloj de coordinación, persistencia de estado y pruebas específicas del backend.

PublicNetworkWebhookDestinationPolicy reduce el riesgo de SSRF para URL no confiables, pero no reemplaza autorización de cambios, allow-lists, controles de egress ni protección contra DNS rebinding en el momento de la conexión. HTTPS protege la confidencialidad; HMAC autentica el contenido, no cifra el payload.

Resultados y aprendizajes

El beneficio verificable es hacer que la falla, el ownership y la recuperación formen parte del modelo.

En lugar de ocultar las fallas dentro de un ciclo HTTP, el diseño registra cuándo existe el trabajo, quién puede modificarlo, cuándo es elegible un nuevo intento y qué estados terminan el flujo. Esto permite razonar sobre concurrencia y recuperación con evidencia comprobable.

La principal lección está en los límites: los reintentos no crean exactly-once, los leases no crean durabilidad y HMAC no crea confidencialidad. El resultado arquitectónico depende de la combinación entre el protocolo de la biblioteca, el adapter persistente, la operación observable y el receiver idempotente.