Estudo de caso arquitetural · biblioteca open source

Entrega confiável de webhooks em sistemas distribuídos

Uma plataforma de comércio eletrônico precisa comunicar eventos de pedidos mesmo quando destinatários apresentam timeout, erros HTTP, indisponibilidade ou recebem uma mesma entrega mais de uma vez.

O cenário de e-commerce é ilustrativo. As garantias descritas correspondem à API e aos testes da versão 1.0.0; não representam adoção, benchmark ou operação real em produção.

Contexto e problema

Um POST direto não preserva intenção quando a rede interrompe a conversa.

Depois de registrar um pedido, enviar HTTP uma única vez mistura a decisão de negócio com uma dependência remota. Timeout não revela se o destinatário processou a requisição; respostas 5xx podem ser temporárias; um 4xx normalmente exige correção, não repetição infinita.

Se o processo cair após o destinatário aceitar a mensagem e antes de persistir o sucesso, uma tentativa posterior pode duplicar o evento. A solução precisa tornar fila, tentativas, propriedade temporária do trabalho e estados finais observáveis, sem transformar incerteza distribuída em promessa de exactly-once.

Requisitos arquiteturais

Confiabilidade é um conjunto de contratos, não apenas uma política de retry.

01

Confiabilidade

Com store durável e conforme, trabalho aceito pode sobreviver ao processo e ser retomado em modelo at-least-once.

02

Idempotência

IDs estáveis tornam o enqueue idempotente; o destinatário ainda precisa evitar efeitos duplicados.

03

Escalabilidade

Concorrência limitada evita claims além da capacidade; leases coordenam workers que compartilham o store.

04

Segurança

HTTPS é o padrão; HMAC-SHA256 autentica o envelope quando signer e secret provider são registrados.

05

Observabilidade

Logs estruturados, ActivitySource e Meter expõem fila, tentativas, desfechos e duração.

06

Recuperação

Lease expirada permite reclaim; retry persiste o próximo instante; estados terminais deixam de ser claimable.

At-least-onceIdempotênciaConcorrência limitadaSegurançaObservabilidadeEvolutibilidade

Arquitetura da solução

Separar intenção, coordenação, transporte e política torna cada garantia explícita.

Fluxo do ReliableWebhooks: aplicação enfileira no store, dispatcher obtém lease, transporte envia ao destinatário e persiste sucesso, retry, falha permanente ou dead letter, com telemetria.
O core define o protocolo. Durabilidade real depende do IWebhookDeliveryStore fornecido pela aplicação, e idempotência de efeitos depende do destinatário.
  1. 1. EnqueueIWebhookEnqueueService valida limites e usa o ID estável.
  2. 2. PersistênciaIWebhookDeliveryStore guarda mensagem, estado e agendamento.
  3. 3. ClaimWebhookDispatcher reclama somente trabalho vencido dentro da capacidade.
  4. 4. LeaseToken e expiração identificam o owner ativo e rejeitam gravação obsoleta.
  5. 5. TransporteIWebhookDeliveryTransport representa uma tentativa HTTP.
  6. 6. TransiçãoSucesso, retry, falha permanente ou dead letter são persistidos.

Enqueue e ID estável

O serviço público valida os limites configurados antes de persistir. O ID identifica o par evento–subscriber; enfileirá-lo novamente deve retornar o registro existente sem substituir payload, destino ou estado.

Implementação do enqueue ↗

Store como porta

IWebhookDeliveryStore isola tecnologia de persistência, mas exige claim atômico, renovação, rejeição de owner obsoleto e transições terminais. O pacote não inclui adapter durável.

Contrato de persistência ↗

Dispatcher e leases

O dispatcher preenche apenas slots disponíveis, renova a lease durante a tentativa e cancela o trabalho quando perde ownership. No shutdown, para novos claims e aguarda o grace period.

Código do dispatcher ↗

Transporte substituível

Uma chamada a SendAsync é uma tentativa. O transporte HTTP classifica 2xx como sucesso, 408, 425, 429 e 5xx como retryable; demais status são permanentes.

Classificador padrão ↗

Backoff com jitter

A política padrão usa backoff exponencial limitado e jitter positivo. Um Retry-After válido pode postergar além do limite local; ao atingir o máximo de tentativas, o item vira dead letter.

Política padrão ↗

HMAC e telemetria

O signer HMAC-SHA256 cobre timestamp, ID, tipo de evento, content type e bytes exatos do payload. Instrumentação publica nomes estáveis de traces e métricas sem incluir payload, assinatura ou destino.

Signer HMAC ↗

API pública

A aplicação registra persistência, configura o dispatcher e enfileira uma mensagem imutável.

// Produção: a aplicação fornece um store durável e 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() é opt-in. A aplicação também pode resolver WebhookDispatcher e controlar seu ciclo de vida diretamente.

Cenários de execução

As garantias aparecem nos caminhos de falha, não apenas no happy path.

Entrega bem-sucedida

O dispatcher obtém uma lease, o transporte recebe 2xx e o store executa MarkSucceededAsync. Succeeded é terminal e não volta aos claims normais.

Garantia: uma transição de sucesso para o owner atual; duplicatas ainda são possíveis se o endpoint aceitou e a persistência do sucesso falhou.

Timeout seguido de retry

O transporte retorna falha retryable. A política calcula o próximo instante com backoff/jitter e o store executa ScheduleRetryAsync, libera a lease e preserva o contador.

Garantia: nova tentativa após o agendamento quando o store é conforme; timeout não prova que o destinatário deixou de processar.

Falha permanente

Um status como 400 é classificado como permanente por padrão. O dispatcher chama MarkPermanentlyFailedAsync; o item não é repetido automaticamente.

Garantia: estado terminal rastreável. Falhas retryable esgotadas terminam em DeadLettered, também sem replay automático.

Worker reiniciado

Com store durável, trabalho pendente permanece disponível e uma lease abandonada pode ser reclamada após expirar. O token anterior não pode gravar uma transição atrasada.

Limite: com InMemoryWebhookDeliveryStore, o restart perde a fila; não existe recuperação durável nesse modo.

Decisões e trade-offs

A biblioteca controla o protocolo; a aplicação conserva as decisões dependentes do domínio e da infraestrutura.

At-least-once, não exactly-once

Reenvio depois de resultado remoto incerto preserva a chance de entrega, mas aceita duplicidade. Exactly-once sobre HTTP exigiria coordenação transacional que a biblioteca e o destinatário não compartilham.

Leases, não execução descoordenada

Ownership temporário permite escalar workers e recuperar abandono. Em troca, o store precisa de claim atômico, relógio autoritativo e compare-and-set do token.

Retry com backoff

Espaçar tentativas reduz pressão sobre um destinatário indisponível; jitter reduz sincronização entre workers. A latência de recuperação cresce e cada política precisa refletir o contrato remoto.

Persistência extensível

Não acoplar o core a banco ou ORM mantém a biblioteca reutilizável. O custo é explícito: a aplicação implementa e opera um adapter durável conforme para produção.

Idempotência no destinatário

O ID estável ajuda a reconhecer duplicatas, mas a biblioteca não controla o efeito remoto. O receiver escolhe uma chave autenticada adequada e persiste sua própria decisão idempotente.

Portas separadas

Store, transporte, classificador, retry, signer e providers podem evoluir isoladamente. A composição ganha flexibilidade, mas implementações customizadas devem preservar os contratos documentados.

Divisão de responsabilidades entre ReliableWebhooks e a aplicação consumidora
Responsabilidade da bibliotecaResponsabilidade da aplicação
Protocolo de enqueue, claim, lease, tentativa e transição.Fornecer persistência durável e atômica adequada ao ambiente de produção.
Transporte HTTP padrão, classificação e retry configurável.Escolher política, limites, quotas, operação de dead letters e eventual replay.
Signer HMAC-SHA256 e hooks para secret/header providers.Gerenciar secrets, rotação, autorização de destinos e verificação no receiver.
Logs, traces e métricas com nomes públicos estáveis.Coletar, exportar, alertar, definir SLOs e proteger dados persistidos.
ID estável e enqueue idempotente no contrato do store.Garantir idempotência dos efeitos de negócio no destinatário.

Limitações e evolução

Confiabilidade só existe até a fronteira que a implementação realmente sustenta.

A biblioteca não garante exactly-once, transação distribuída com o receiver, idempotência do lado remoto, replay automático de dead letters nem política de rotação de secrets. O destinatário deve tolerar mensagens duplicadas.

InMemoryWebhookDeliveryStore é local ao processo e não é durável. O pacote não entrega store de produção: a aplicação deve implementar um IWebhookDeliveryStore conforme, incluindo atomicidade, lease, relógio de coordenação, persistência de estado e testes específicos do backend.

O PublicNetworkWebhookDestinationPolicy reduz risco de SSRF para URLs não confiáveis, mas não substitui autorização de alterações, allow-lists, controles de egress ou proteção contra DNS rebinding no momento da conexão. HTTPS protege confidencialidade; HMAC autentica conteúdo, não cifra payload.

Resultados e aprendizados

O ganho verificável é tornar falha, ownership e recuperação parte do modelo.

Em vez de esconder falhas dentro de um loop HTTP, o desenho registra quando o trabalho existe, quem pode alterá-lo, quando uma nova tentativa é elegível e quais estados encerram o fluxo. Isso permite raciocinar sobre concorrência e recuperação com evidência testável.

A principal lição é de fronteira: retries não criam exactly-once, leases não criam durabilidade e HMAC não cria confidencialidade. O resultado arquitetural depende da combinação entre protocolo da biblioteca, adapter persistente, operação observável e receiver idempotente.