Webhook duplicado: como deduplicar com chave de idempotência sem travar o endpoint

Escrito por

em

O provedor de pagamentos reenviou o mesmo evento payment.succeeded três vezes em quatro minutos, e o sistema liberou três licenças. O endpoint respondeu 200 na primeira, mas demorou mais que o timeout do provedor, que tentou de novo. Esse é o comportamento documentado de praticamente todo emissor de webhook: entrega pelo menos uma vez, nunca exatamente uma. Quem recebe é quem precisa garantir que processar duas vezes dê o mesmo resultado que processar uma.

Por que a entrega duplicada é a regra, não a exceção

Emissores de webhook tratam qualquer resposta que não seja 2xx dentro do prazo como falha e reenviam com backoff. Timeout de rede, deploy no meio da requisição, pico de CPU e até um 200 que se perdeu no caminho de volta produzem o mesmo efeito: o evento chega de novo. Alguns provedores também reentregam em massa depois de um incidente próprio. Construir o receptor assumindo entrega única é construir um bug que só aparece sob carga ou em dia ruim de infraestrutura.

O segundo ponto é a ordem. Eventos de um mesmo objeto podem chegar fora de sequência: um invoice.paid processado antes do invoice.created. Deduplicação resolve repetição, não ordenação, e as duas coisas costumam ser confundidas no mesmo ticket.

Chave de idempotência: de onde ela vem

A chave precisa identificar o evento, não o objeto de negócio. Usar o ID do pedido como chave bloqueia legitimamente eventos distintos do mesmo pedido (pago, estornado, reembolsado). A fonte correta, nesta ordem de preferência:

  • O ID do evento entregue pelo provedor, quase sempre presente no corpo ou em cabeçalho próprio.
  • Um hash determinístico do corpo bruto da requisição, quando o provedor não manda ID. Frágil se o emissor reserializar o JSON entre tentativas.
  • Uma combinação de tipo de evento, ID do objeto e timestamp do provedor, como último recurso.

A verificação de assinatura HMAC vem antes de tudo e usa o corpo bruto, byte a byte. Parsear e reserializar o JSON antes de validar quebra a assinatura em silêncio, e a correção típica (desligar a validação “temporariamente”) vira dívida permanente.

Opção A: tabela de eventos com restrição única

A abordagem mais barata e mais robusta para a maioria dos times: uma tabela com a chave como UNIQUE e a inserção dentro da mesma transação do efeito de negócio.

BEGIN;
INSERT INTO eventos_processados (evento_id, recebido_em)
VALUES ($1, now())
ON CONFLICT (evento_id) DO NOTHING;
-- se 0 linhas afetadas: duplicado, faça COMMIT e responda 200
-- senão: aplique o efeito (liberar licença, creditar saldo)
COMMIT;

A garantia vem do banco, não do código da aplicação. Duas requisições simultâneas com o mesmo evento disputam a restrição única, e só uma passa. O erro clássico é fazer SELECT para ver se o evento existe e depois INSERT: entre os dois comandos há uma janela onde a segunda requisição também enxerga “não existe”. Em carga real, essa janela é atingida.

O custo é crescimento da tabela. Uma rotina que apaga registros mais velhos que a janela de reentrega do provedor (em geral dias, não meses; confirme na documentação de cada um) resolve sem perder a proteção.

Opção B: enfileirar primeiro, processar depois

Nesta variante o endpoint só valida a assinatura, grava o payload bruto numa fila e responde 200 imediatamente. Workers consomem a fila e aplicam a deduplicação lá. O ganho é de latência e resiliência: o provedor nunca vê timeout, e um bug no processamento não perde o evento, que fica retido para nova tentativa.

O custo é operacional. Surge uma fila para monitorar, uma política de mensagens mortas e a pergunta de quem avisa quando o worker parou. A fila também não elimina a necessidade da tabela da opção A: filas padrão entregam pelo menos uma vez, e o worker precisa ser idempotente do mesmo jeito. Quem adota fila esperando “exactly-once” de graça descobre o contrário em produção.

Comparativo e critério de escolha

Critério Tabela + UNIQUE na transação Fila + worker idempotente
Latência da resposta ao provedor Depende do efeito de negócio Constante e baixa
Componentes novos Uma tabela Fila, workers, política de mensagens mortas
Reprocessamento após bug Exige reentrega pelo provedor Reprocessa da própria fila
Proteção contra duplicata Restrição única Ainda precisa de restrição única
Quando o efeito é lento (e-mail, PDF, integração externa) Risco de timeout Sem risco

O critério é o tempo do efeito de negócio. Se aplicar o evento é uma escrita no próprio banco e termina em dezenas de milissegundos, a opção A basta e a fila é complexidade sem retorno. Se o processamento chama serviços externos, gera documentos ou pode passar de alguns segundos, a opção B deixa de ser luxo: o timeout do provedor passa a ditar a arquitetura. Em qualquer dos dois cenários, a tabela de eventos fica. A fila é camada adicional, não substituta.

Fora de ordem: o problema que a deduplicação não resolve

Deduplicar garante que um evento seja aplicado uma vez. Não garante que seja aplicado na hora certa. Considere uma assinatura que recebe updated (troca de plano) e, dois segundos depois, canceled. Se a rede entregar o segundo antes do primeiro, e o receptor simplesmente gravar o estado do payload, a assinatura termina como “plano novo, ativa”, exatamente o contrário do que aconteceu.

Há três estratégias reais, com custos diferentes. A primeira é comparar versão: guardar, por objeto, o timestamp ou número de versão do último evento aplicado e descartar qualquer um mais antigo. Funciona quando o provedor expõe um campo confiável de ordenação. A segunda é buscar o estado atual na API do provedor ao receber o evento e usar o webhook apenas como gatilho. Custa uma chamada extra por evento e depende de limite de requisições do provedor, mas elimina a classe inteira de bugs de ordem. A terceira é serializar o processamento por objeto, particionando a fila pelo ID do objeto, o que preserva a ordem de chegada, não a ordem de emissão, e portanto só ajuda se o provedor entregar em sequência.

A escolha prática: para pagamentos e assinaturas, onde um estado errado custa dinheiro, prefira buscar o estado atual. Para eventos informativos (log de atividade, notificação), comparar versão é suficiente e mais barato.

O que monitorar depois que está no ar

Três números dizem se a implementação está saudável. A taxa de duplicatas descartadas: um valor perto de zero por semanas indica que o provedor está estável ou, pior, que a deduplicação não está sendo exercitada e ninguém sabe se funciona. A idade do evento mais antigo não processado, se houver fila: é o único alerta que pega worker travado antes de o cliente reclamar. E a taxa de assinaturas inválidas: um salto aqui é rotação de segredo mal feita ou tentativa de forjar eventos, e os dois pedem ação no mesmo dia.

Registre a chave de idempotência em todo log do fluxo. Quando alguém abrir o chamado “cobrado em duplicidade”, a pergunta “esse evento chegou quantas vezes e o que cada tentativa fez” precisa ter resposta em uma consulta, não em uma tarde.

Armadilhas que sobrevivem à revisão de código

Responder 200 em caso de duplicata, e não 409. Para o provedor, o evento foi entregue; devolver erro só alimenta mais reentregas. Registrar a chave antes de o efeito ser confirmado e sem transação: se o processo morrer no meio, o evento fica marcado como feito e nunca é aplicado. Tratar eventos fora de ordem aplicando o estado do payload sem comparar com o estado atual: um subscription.updated atrasado sobrescreve um cancelamento mais recente. Para isso, guarde o timestamp ou a versão do objeto no provedor e ignore eventos mais antigos que o último aplicado.

Teste de aceitação mínimo: dispare o mesmo payload assinado 20 vezes em paralelo contra o endpoint e confirme que o efeito de negócio ocorreu uma única vez. Se o teste não existe na suíte, a idempotência é uma crença, não uma propriedade.

Comentários

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *