Alguém foi cobrado uma vez e recebeu o e-mail duas vezes. Ou um pedido foi despachado duas vezes, ou duas pessoas apareceram para o mesmo horário das 10:00. Você vasculha os logs, encontra o mesmo event.id processado às 14:02:07 e de novo às 14:02:41, e o primeiro instinto é achar que o provedor está com defeito.
O provedor não está com defeito. Ele está fazendo exatamente o que promete. A Stripe, a Shopify, a Twilio e a maioria dos outros remetentes de webhooks que importam garantem entrega pelo menos uma vez (at-least-once), e at-least-once é um termo técnico preciso: significa que duplicatas fazem parte do contrato. Construir como se a entrega fosse exatamente uma vez é o bug de verdade, e é um bug que só aparece em produção, normalmente no seu horário de maior movimento.
TL;DR
- Entrega pelo menos uma vez significa que duplicatas vão acontecer com certeza, mais cedo ou mais tarde, e não só raramente
- Entrega exatamente uma vez não se compra a preço nenhum; processamento exatamente uma vez, sim, e é você quem o constrói do seu lado
- Um índice único no ID de evento do provedor resolve 90% disso com uma linha de SQL
- Efeitos colaterais não transacionais (e-mails, APIs de terceiros) precisam de um padrão claim-then-commit (reservar e depois confirmar)
- Chegada fora de ordem é o problema irmão que ninguém planeja, e ela pede versionamento, não deduplicação
Por que ninguém vende entrega exatamente uma vez
Imagine a posição do remetente. Ele faz o POST do seu evento, e a conexão TCP cai antes de a resposta voltar. Pode ser que seu servidor tenha processado tudo e a confirmação tenha se perdido, ou pode ser que seu servidor tenha caído antes de tocar no evento. Vistos de fora, os dois estados são idênticos.
O remetente tem exatamente duas opções. Tentar de novo e, se você já tinha processado o evento, agora você tem uma duplicata. Ou não tentar de novo e, se você nunca o processou, o evento está perdido para sempre.
Esse é o dilema inteiro, e não existe uma terceira porta. A maioria dos provedores escolhe tentar de novo, porque um webhook de pagamento duplicado é um incômodo que dá para contornar com engenharia, e um webhook de pagamento que não chega é perda de receita silenciosa que você não consegue detectar. Eles fizeram a escolha certa. Isso só significa que a deduplicação precisa morar em algum lugar, e o único lugar com contexto suficiente é seu banco de dados.
De onde vêm as duplicatas no mundo real, mais ou menos na ordem de frequência com que as vemos:
- Sua resposta demorou e o remetente deu timeout, mas seu handler terminou mesmo assim
- Um load balancer ou proxy na frente da sua aplicação respondeu 502 depois que sua aplicação respondeu 200
- Seu handler teve sucesso, depois lançou uma exceção durante a limpeza, e seu framework transformou isso num 500
- Um deploy matou o processo depois da escrita, mas antes da resposta
- Você clicou em "reenviar" num painel e esqueceu
Repare que quatro desses cinco são casos em que o trabalho foi feito e a confirmação se perdeu. Esse é o caso normal, não um caso exótico.
Padrão 1: índice único no ID do evento
Se for fazer uma coisa só, faça esta.
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id, provider)
VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING`,
[event.id, "stripe"],
);
if (rowCount === 0) return new Response(null, { status: 200 }); // already handled
await doTheWork(event);
Dois detalhes que muita gente erra.
Responda 200 para a duplicata, não 409. Uma duplicata que você ignorou corretamente é um sucesso do ponto de vista do remetente. Responder com erro faz o remetente tentar de novo entregar aquilo que você acabou de dizer que já tem, e você construiu um loop.
O ID precisa ser o do provedor, não o seu: event.id da Stripe, X-GitHub-Delivery do GitHub, X-Shopify-Webhook-Id da Shopify. Usar o hash do corpo do payload é um substituto tentador e uma armadilha: quando o remetente não põe nenhum ID no corpo, dois eventos genuinamente distintos podem ter corpos idênticos byte a byte. Dois pedidos do mesmo valor, do mesmo cliente, com um segundo de diferença, são dois pedidos reais. Deduplique pelo hash do corpo e você acabou de se recusar a liberar um pedido que alguém pagou.
Quando um provedor não dá ID nenhum, você está inventando uma chave sintética a partir dos campos de negócio que forem de fato únicos. É uma situação pior, e vale um comentário no código explicando a escolha.
Padrão 2: coloque o marcador na mesma transação que o trabalho
O padrão 1 tem um buraco. Se o insert faz commit e depois doTheWork lança uma exceção, você marcou o evento como processado sem processá-lo. A nova tentativa (retry) é ignorada e o evento se perde em silêncio, uma falha estritamente pior do que a duplicata que você tentava evitar.
Quando o trabalho é no banco de dados, feche o buraco com uma transação:
await db.transaction(async (tx) => {
const { rowCount } = await tx.query(
`INSERT INTO processed_events (event_id, provider)
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[event.id, "stripe"],
);
if (rowCount === 0) return; // duplicate, commit an empty tx
await tx.query(`UPDATE orders SET status = 'paid' WHERE id = $1`, [orderId]);
});
Ou as duas coisas acontecem, ou nenhuma. Esse é o formato correto, e ele deve ser sua escolha padrão.
Padrão 3: claim-then-commit para efeitos colaterais que não dá para desfazer
Transações não se estendem ao Resend, ao Salesforce ou a uma API de frete. Depois que o e-mail sai, ele saiu. Aqui, a jogada honesta é um marcador em duas fases:
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
status TEXT NOT NULL, -- 'processing' | 'done'
claimed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
Reserve o evento antes de fazer qualquer coisa. Faça o trabalho. Marque como concluído. Uma duplicata concorrente que chega no meio do caminho vê processing e recua, em vez de disputar corrida com você.
A parte que exige uma decisão: o que acontece com as linhas presas em processing porque o processo morreu entre a reserva e o commit? Você não tem como saber se o e-mail saiu. As duas escolhas são defensáveis, e a decisão deve ser deliberada:
// Rows stuck in 'processing' >15min are re-claimed. Biases toward
// a possible duplicate email over a definitely-missing one. Flip this for
// side effects where duplication costs money (charges, shipments).
const STALE_CLAIM_MS = 15 * 60 * 1000;
Para recibos e notificações, reservar de novo é o certo, porque um segundo e-mail sai barato. Para qualquer coisa que movimente dinheiro ou mercadorias, deixe a linha presa e avise um humano. Registrar qual das duas você escolheu, e por quê, num comentário ao lado da constante vai poupar a próxima pessoa de uma hora muito confusa.
Padrão 4: condição de versão para entrega fora de ordem
A deduplicação não ajuda aqui, e esta é a falha que de fato corrompe dados.
Com novas tentativas, o evento #2 pode passar na frente do evento #1, e o cronograma de backoff é justamente o que abre essa brecha: uma entrega que espera cinco minutos chega depois de tudo o que foi enviado nesses cinco minutos. Uma assinatura é atualizada para active às 14:00:01 e para cancelled às 14:00:03. A primeira entrega falha e é tentada de novo cinco minutos depois. Agora seu banco de dados diz active, e o cliente continua com acesso a algo que cancelou. Nenhum evento foi processado duas vezes. O resultado continua errado.
A idempotência não ajuda aqui. O que você precisa é de uma condição monotônica na cláusula WHERE:
UPDATE subscriptions
SET status = $1, updated_at = $2
WHERE id = $3
AND updated_at < $2; -- refuse to apply stale state
Use qualquer campo monotônico que o provedor forneça: um campo version, um updated_at do próprio objeto. Não use seu próprio NOW(), que reflete quando você recebeu o evento, e essa é justamente a ordem que está errada.
Alguns provedores não dão nem um ID único nem uma ordem. O HubSpot documenta que o eventId dele não tem garantia de ser único e envia eventos em lotes sem nenhuma promessa de ordem; o que usar para deduplicar no lugar dele é um problema à parte.
Para máquinas de estados com invariantes reais, vá além e rejeite de cara as transições ilegais, em vez de depender de timestamps. Uma transição de expirada para ativa deve gerar um erro, não ser aplicada em silêncio.
Uma observação sobre como testar isso
Você não encontra esses bugs enviando um webhook e conferindo o resultado. Os testes que valem a pena escrever são:
- Envie o mesmo evento duas vezes seguidas. Confirme que houve um único efeito colateral
- Envie o mesmo evento duas vezes em paralelo. É esse teste que encontra o índice único que falta, e um teste sequencial nunca vai falhar
- Envie dois eventos fora de ordem. Confirme que o estado mais novo prevalece
- Mate o processo entre a escrita do marcador e o efeito colateral. Confirme o que você decidiu no padrão 3
O teste em paralelo é o que tem valor. A maior parte do código de idempotência é escrita supondo entrega em série e cai na primeira vez que duas novas tentativas chegam a duas instâncias no mesmo milissegundo. Escrevemos mais sobre a distância entre testes de webhook que passam e código de webhook que funciona em Seus testes de assinatura de webhook não provam nada.
Onde o AnyHook entra
O AnyHook não elimina a necessidade de idempotência do seu lado, e qualquer serviço que diga o contrário está prometendo demais. Duplicatas nascem no remetente e podem surgir na rede entre nós e você, uma camada que ninguém consegue eliminar.
O que ele muda é sua capacidade de enxergar o problema:
- O AnyHook registra cada evento com a requisição completa, o número de tentativas e a última resposta, então "isso disparou duas vezes, ou meu handler rodou duas vezes numa única entrega" tem resposta no painel, em vez de exigir cruzar três fontes de log às 2h da manhã
- O número da tentativa aparece em cada entrega, então dá para distinguir uma nova tentativa de um evento inédito
- O cabeçalho
X-AnyHook-Deliveryleva o mesmo ID de evento em todas as novas tentativas de um evento, o que dá a você uma chave de deduplicação para as duplicatas que surgem entre nós e você, mesmo quando o provedor de onde o evento vem não oferece uma - O replay (um novo envio de um evento armazenado, feito por você) é deliberado e marcado. Eventos que passaram por replay recebem a flag
is_replay, então preencher uma lacuna (backfill) não vira um pico misterioso de duplicatas uma semana depois - Qualquer adulteração no log é detectável. Quando a pergunta é "o que exatamente chegou, e esta linha foi editada", um banco de dados mutável é uma alegação, não uma prova: Um log de webhooks com adulteração detectável
Conclusão
Duplicatas não são um defeito do seu provedor; são o preço de nunca perder um evento, e é a troca certa. Coloque hoje um índice único no ID de evento do provedor. Leve o marcador para dentro da transação na próxima vez que mexer no código. E, se você lida com qualquer coisa que tenha ciclo de vida (assinaturas, pedidos, deploys), adicione a condição de versão antes que uma entrega fora de ordem grave um estado desatualizado que você só vai notar daqui a um mês.