Validar a assinatura impede payloads adulterados, mas não impede que uma entrega válida seja processada mais de uma vez. O GitHub recomenda usar X-GitHub-Delivery para identificar cada entrega e informa que uma redelivery mantém o mesmo valor do header original.
Importante: o GitHub não redelivera automaticamente entregas que falharam. Redeliveries podem ser disparadas manualmente ou por automação usando a API. Independentemente da origem, o consumidor deve ser seguro contra repetição.
Este guia usa um padrão de inbox idempotente com quatro etapas:
claim → process → complete/fail
A ideia é simples: antes de executar qualquer efeito de negócio, o consumidor tenta reservar atomicamente o identificador da entrega. Apenas um worker pode ganhar essa reserva.
- Leia o corpo bruto.
- Valide
X-Hub-Signature-256. - Valide limites básicos do request e o evento permitido.
- Leia
X-GitHub-Deliverycomo identificador opaco e limite seu tamanho antes de persistir. - Faça o
claimatômico da delivery. - Responda
2xxrapidamente e processe o trabalho na fila quando o fluxo for assíncrono. - Marque a delivery como
completedoufailedconforme o resultado.
Nunca reserve um X-GitHub-Delivery antes de validar a assinatura. Caso contrário, um atacante pode preencher seu armazenamento com identificadores arbitrários e bloquear deliveries legítimas.
Uma implementação simples pode manter estes campos:
| Campo | Uso |
|---|---|
delivery_id |
valor de X-GitHub-Delivery; chave única no escopo do provedor |
status |
processing, completed ou failed |
claim_token |
token aleatório ou versão usada para identificar o worker que possui a reserva |
lease_until |
limite para considerar um processing abandonado |
attempts |
número de tentativas aceitas pelo consumidor |
created_at |
primeira vez em que a delivery foi observada |
completed_at |
quando o processamento terminou com sucesso |
failed_at |
última falha conhecida |
Se o mesmo serviço recebe eventos de mais de um provedor, use uma chave composta como provider + delivery_id. Se quiser separar múltiplos webhooks GitHub dentro do mesmo serviço, acrescente também o identificador lógico do webhook à chave.
O ponto crítico é que exists() seguido de insert() não é seguro. Dois workers podem consultar ao mesmo tempo, ambos receberem "não existe" e executarem o mesmo efeito.
A reserva precisa ser uma única operação atômica: constraint UNIQUE, INSERT ... ON CONFLICT, SET NX, compare-and-set ou mecanismo equivalente.
Pseudocódigo independente de framework:
function receiveWebhook(rawBody, headers):
verifySignature(rawBody, headers["X-Hub-Signature-256"])
deliveryId = requireBoundedOpaqueId(headers["X-GitHub-Delivery"])
event = requireAllowedEvent(headers["X-GitHub-Event"])
claim = inbox.tryClaim(
provider = "github",
deliveryId = deliveryId,
lease = 2 minutes
)
if claim.state == "completed":
return 200
if claim.state == "processing_by_other_worker":
return 202
if claim.state == "retry_not_allowed":
return 202
enqueue({
deliveryId: deliveryId,
event: event,
claimToken: claim.token,
rawBody: rawBody
})
return 202
No worker:
function processJob(job):
if !inbox.ownsClaim(job.deliveryId, job.claimToken):
return
try:
performDomainOperationIdempotently(job.deliveryId, job.rawBody)
inbox.complete(job.deliveryId, job.claimToken)
catch transientError:
inbox.fail(job.deliveryId, job.claimToken, retryable = true)
throw transientError
catch permanentError:
inbox.fail(job.deliveryId, job.claimToken, retryable = false)
O claim_token funciona como um fencing token: um worker antigo não deve conseguir marcar a delivery como concluída depois que a lease expirou e outro worker a recuperou. Atualizações de complete e fail devem exigir que o token ainda seja o dono atual da reserva.
Ao receber a mesma delivery enquanto outra execução está ativa:
completed: não execute o efeito novamente; responda2xx.processingcom lease válida: trate como duplicata em andamento; não enfileire um segundo trabalho.processingcom lease expirada: um único worker pode recuperar a delivery com compare-and-set e gerar um novoclaim_token.failedretryable: permita nova tentativa apenas por transição atômica e respeite limite/backoff.failedpermanente: mantenha o registro e não repita automaticamente o efeito.
Isso evita duas classes comuns de bug: concorrência simultânea e reprocessamento após redelivery.
O lease_until existe exatamente para esse caso. Se um worker reservar a delivery e morrer antes de concluí-la, a reserva não pode ficar presa para sempre.
Uma estratégia prática:
processingrecebe uma lease curta, maior que o tempo esperado de processamento.- O worker pode renovar a lease enquanto ainda possui o mesmo
claim_token. - Se a lease expirar, outra execução pode recuperar a delivery atomicamente.
- O worker antigo perde o direito de fazer
complete/failquando o token muda.
Atenção ao ponto mais difícil: se o worker executar um efeito externo e morrer depois do efeito, mas antes de marcar completed, uma nova tentativa pode repetir esse efeito. O registro de delivery sozinho não cria garantia de "exactly once".
Para fechar essa janela:
- quando o efeito é no mesmo banco, grave a alteração de negócio e
completedna mesma transação; - quando o efeito é uma chamada externa, envie uma idempotency key derivada da delivery se a API de destino suportar;
- para fluxos mais complexos, use Inbox/Outbox e torne a operação de domínio idempotente pela própria chave de negócio.
O objetivo correto é at-least-once delivery + efeitos idempotentes, e não depender de uma promessa impossível de exactly-once entre sistemas independentes.
Não use o mesmo TTL para todos os estados.
Sugestão inicial, a ser ajustada ao risco e ao volume:
| Estado | Retenção sugerida | Motivo |
|---|---|---|
processing |
lease de 30 s a poucos minutos | liberar claims abandonados rapidamente |
failed |
dias ou semanas | permitir diagnóstico e retry controlado |
completed |
pelo menos 7 dias; frequentemente 30 dias ou mais | cobrir redeliveries, automações e replay dentro da janela de retenção |
A documentação do GitHub permite redelivery de entregas recentes e, atualmente, a interface/API trabalha com deliveries dos últimos 3 dias. Manter completed por pelo menos 7 dias dá uma margem operacional simples, mas isso não é um limite de segurança. Se sua ameaça inclui replay tardio ou se o custo de duplicação é alto, retenha a chave por mais tempo conforme sua política.
Não use apenas o TTL de processing como deduplicação: assim que ele expirar, uma delivery já concluída voltaria a ser aceita.
Bom padrão quando o efeito de negócio também está no banco. Use índice único e, quando possível, a mesma transação para inbox + alteração de domínio.
Exemplo conceitual:
CREATE TABLE webhook_deliveries (
provider VARCHAR(32) NOT NULL,
delivery_id VARCHAR(128) NOT NULL,
status VARCHAR(16) NOT NULL,
claim_token VARCHAR(64) NULL,
lease_until TIMESTAMP NULL,
attempts INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP NULL,
failed_at TIMESTAMP NULL,
PRIMARY KEY (provider, delivery_id)
);A constraint é a proteção principal contra dois claims simultâneos. A sintaxe exata do upsert depende do banco.
SET key token NX PX ... é útil para claims rápidos, mas um lock temporário sozinho não representa histórico de conclusão. Mantenha um marcador de completed com TTL separado ou persista o resultado em armazenamento durável.
Se a perda do Redis puder causar efeitos duplicados inaceitáveis, ele não deve ser a única fonte de deduplicação.
Uma fila ajuda a responder ao GitHub rapidamente, mas não substitui a idempotência. Mesmo filas com recursos de deduplicação normalmente têm janelas limitadas. Faça o claim no ponto em que você controla atomicidade e trate o worker como potencialmente executável mais de uma vez.
Retry deve ser explícito:
- erros transitórios: timeout, indisponibilidade temporária, rate limit;
- erros permanentes: payload semanticamente inválido, recurso inexistente sem possibilidade de recuperação, evento não suportado.
Use backoff exponencial com jitter para falhas transitórias e limite de tentativas. Uma delivery permanentemente falha deve poder ser inspecionada ou enviada para uma dead-letter queue sem entrar em loop infinito.
Registre metadados úteis:
delivery_id;X-GitHub-Eventeactionquando aplicável;- status (
processing,completed,failed); - número da tentativa;
- duração;
- classe/código do erro;
- timestamps de claim, conclusão e falha.
Evite registrar:
- webhook secret;
- header de assinatura completo;
- tokens de API;
- payload completo por padrão;
- campos sensíveis recebidos dentro do evento.
Se precisar guardar payload para troubleshooting ou replay interno, defina criptografia, controle de acesso e política de retenção separadamente.
- Assinatura validada antes do claim.
-
X-GitHub-Deliveryobrigatório e com tamanho limitado. - Chave única por provedor/delivery.
- Claim realmente atômico.
- Duplicata
completedretorna2xxsem novo efeito. - Duplicata
processingnão cria um segundo job. - Lease permite recuperar worker morto.
-
claim_token/versão impede worker antigo de finalizar claim novo. - Efeito de domínio também é idempotente.
-
completedtem retenção maior que a lease. - Retry tem backoff, limite e distinção entre erro transitório/permanente.
- Logs não contêm secret nem payload sensível por padrão.