Webhooks que chegam duas vezes: checklist para um receptor idempotente

Escrito por

em

O provedor de pagamento reenviou o mesmo evento invoice.paid três vezes em quarenta segundos porque o seu endpoint demorou 11 segundos para responder. Se o handler não é idempotente, o cliente recebe três e-mails de confirmação e, no pior caso, três liberações de crédito. Quase todo provedor documenta entrega “ao menos uma vez”; garantir o “exatamente uma vez” é trabalho do receptor.

O checklist abaixo parte de um receptor em produção, com fila e banco relacional, e está na ordem em que as falhas costumam aparecer.

Validar a assinatura antes de qualquer outra coisa

A validação HMAC precisa usar o corpo bruto da requisição, byte a byte. O erro clássico é deixar o framework fazer o parse do JSON e recalcular a assinatura sobre a serialização do objeto: espaços e ordem de chaves mudam, a assinatura deixa de bater e a equipe “resolve” desligando a verificação. Configure o middleware para expor o corpo cru nessa rota, compare com comparação em tempo constante e rejeite com 400 sem registrar o payload completo no log.

Vale também checar o timestamp que vem junto da assinatura. Uma janela de poucos minutos barra replay de requisição capturada, desde que o relógio do servidor esteja sincronizado por NTP.

Responder rápido e processar depois

O receptor faz três coisas e nenhuma mais: valida, persiste o evento e responde 2xx. Todo o resto vai para a fila. Provedores costumam considerar falha qualquer resposta que passe de poucos segundos, e cada retry soma carga justamente quando o sistema está lento. Um handler que chama API de terceiros, gera PDF e envia e-mail dentro da requisição é a receita para uma tempestade de reenvios.

A consequência prática é que o 200 significa “recebi e guardei”, não “processei”. O estado do processamento mora na tabela de eventos, não no código HTTP.

Deduplicar com restrição no banco, não com if

Checar “já processei esse id?” com um SELECT seguido de INSERT tem janela de corrida: dois retries simultâneos passam pelo SELECT antes de qualquer INSERT. A deduplicação confiável é uma chave única na tabela de eventos, usando o id que o provedor envia, e tratar a violação como sinal de duplicata.

CREATE TABLE webhook_eventos (
  provedor    text        NOT NULL,
  evento_id   text        NOT NULL,
  tipo        text        NOT NULL,
  payload     jsonb       NOT NULL,
  recebido_em timestamptz NOT NULL DEFAULT now(),
  processado_em timestamptz,
  PRIMARY KEY (provedor, evento_id)
);

INSERT INTO webhook_eventos (provedor, evento_id, tipo, payload)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING;

Se o INSERT afetou zero linhas, é duplicata: responda 200 e encerre. A chave composta evita colisão quando dois provedores usam ids parecidos.

Tornar o efeito colateral idempotente também

A tabela de eventos protege contra reentrega do mesmo evento, mas não contra o worker que caiu no meio do processamento e foi reexecutado. Aqui entra a segunda camada: cada efeito externo precisa de chave própria. Para cobrança ou envio de e-mail, derive a chave do id do evento e passe-a ao serviço que suporta cabeçalho de idempotência. Para efeitos internos, faça a mudança de estado e a marcação de processado_em na mesma transação.

Quando o efeito é uma chamada a um sistema sem suporte a idempotência, a saída é registrar a intenção antes e o resultado depois, e aceitar que existe uma janela residual. Essa janela deve ter dono e alerta, não ser ignorada.

Não confiar na ordem de chegada

Eventos de um mesmo recurso podem chegar invertidos: o subscription.updated aparece antes do subscription.created. Duas estratégias funcionam. A primeira compara o timestamp ou a versão do evento com o último aplicado e descarta o que for mais antigo. A segunda trata o webhook apenas como notificação e busca o estado atual na API do provedor antes de gravar, o que custa uma chamada a mais e elimina a classe inteira de problemas de ordenação.

Para recursos financeiros, a segunda opção é a escolha mais segura. Para eventos de alto volume e baixo risco, como atualização de perfil, a comparação por versão basta e economiza cota de API.

Escalar o consumo sem perder a garantia

Quando o volume cresce, o gargalo costuma ser o worker, não o endpoint. Dois cuidados evitam que o paralelismo desfaça a idempotência. O primeiro é particionar a fila pelo identificador do recurso (cliente, assinatura, pedido), de modo que eventos do mesmo recurso sejam processados em sequência e eventos de recursos diferentes, em paralelo. O segundo é bloquear a linha do evento durante o processamento, com SELECT ... FOR UPDATE SKIP LOCKED ou equivalente, para que dois workers não peguem o mesmo registro quando a fila entrega duas vezes.

Sem essas duas travas, a deduplicação do passo 3 impede o evento de entrar duas vezes, mas não impede que o mesmo evento, já gravado, seja executado em dois workers ao mesmo tempo depois de um timeout de visibilidade mal configurado. É o tipo de falha que só aparece sob carga e deixa um rastro difícil de explicar: dois registros de cobrança com o mesmo id de evento e horários separados por milissegundos.

Um ajuste simples: defina o timeout de visibilidade da fila com folga sobre o pior tempo de processamento medido, e não sobre a média. Se o p99 do handler é de 40 segundos, um timeout de 30 faz a fila reentregar mensagens que ainda estão em execução.

Testar o que quebra, não o caminho feliz

Teste unitário do handler com um payload válido cobre quase nada do que importa. Os cenários que justificam o esforço são: o mesmo evento entregue duas vezes em paralelo, o evento mais novo chegando antes do mais antigo, o worker morrendo entre o efeito externo e a marcação de processado, e a assinatura válida com corpo alterado. Cada um vira um teste de integração com banco real, porque o comportamento de transação e de restrição única é justamente o que se quer verificar, e um mock de repositório esconde a falha.

O provedor geralmente oferece reenvio manual e um modo de teste com eventos de exemplo. Use o reenvio em staging para confirmar que o receptor responde 200 à duplicata sem repetir o efeito, e registre esse teste na pipeline para que uma refatoração futura não o desfaça.

Segurança além da assinatura

A assinatura prova que o evento veio do provedor, mas não que o seu endpoint deve aceitá-lo de qualquer origem. Duas camadas baratas complementam: restringir o endpoint por lista de IPs publicados pelo provedor, quando existir, e usar um segredo por ambiente, de forma que o segredo de staging nunca valide tráfego de produção. Rotacione o segredo com um período de transição em que o receptor aceita a assinatura nova e a antiga, ou a rotação vira incidente.

Evite também registrar o payload completo em log de aplicação. Eventos de pagamento carregam e-mail, nome e, às vezes, parte do documento do cliente. O log deve guardar o id do evento, o tipo e o resultado; o conteúdo fica na tabela de eventos, com política de retenção e acesso restrito.

Um detalhe de operação que costuma ficar de fora: documente, ao lado do código, o que cada tipo de evento deve produzir e quem é o dono dele. Quando um evento cai na fila de mortos às três da manhã, a pessoa de plantão precisa saber se é seguro reprocessar sem consultar quem escreveu o handler meses antes. Um runbook de uma página por tipo de evento resolve isso com custo desprezível.

Dar destino ao que falha

Depois de um número definido de tentativas com backoff exponencial, o evento vai para uma fila de mortos com alerta e uma ferramenta de reprocessamento manual. Sem isso, a única forma de recuperar um evento perdido é pedir ao suporte do provedor que reenvie, e isso vira incidente de horas. Meça duas coisas e coloque as duas em painel: idade do evento não processado mais antigo e taxa de duplicatas descartadas. A segunda costuma subir antes de qualquer reclamação, porque indica que o endpoint está lento e o provedor está insistindo.

Comentários

Deixe um comentário

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