Pular para o conteúdo

Deduplicação e ordem

O mesmo webhook pode chegar mais de uma vez, e os webhooks não chegam necessariamente na ordem em que os fatos aconteceram. As garantias de entrega explicam por quê; esta página é o como.

Todo webhook carrega o header Webhook-Id, que é o mesmo em todas as tentativas da mesma entrega. No envio, o mesmo valor vem no campo id do corpo, para o caso de o seu framework não expor headers com facilidade. No recebimento o corpo é o do provedor, e o id está só no header, com prefixo in_.

A receita é registrar o Webhook-Id numa tabela com chave única e deixar o banco recusar a segunda vez:

CREATE TABLE webhooks_processados (
webhook_id text PRIMARY KEY,
recebido_em timestamptz NOT NULL DEFAULT now()
);
def receber(webhook_id: str, corpo: dict) -> None:
with conexao.transaction():
inserido = conexao.execute(
"INSERT INTO webhooks_processados (webhook_id) VALUES (%s) "
"ON CONFLICT DO NOTHING RETURNING webhook_id",
(webhook_id,),
).fetchone()
# Já processado: responda 200 e não faça nada. Responder erro faria
# o webhook voltar, e a duplicata junto.
if inserido is None:
return
processar(corpo)

Três detalhes fazem essa receita funcionar:

  1. O INSERT e o efeito colateral ficam na mesma transação. Inserir e commitar antes de processar cria a janela em que uma queda no meio deixa o webhook marcado como processado sem ter sido.
  2. Duplicata responde 200. Responder 409 ou 500 faz o retry insistir até esgotar o cronograma, e marca como falha uma entrega que deu certo.
  3. A tabela precisa de retenção. Guarde os ids por mais tempo que o cronograma de retry inteiro (a última tentativa sai cerca de 45 horas depois da primeira) e limpe o resto. Uma semana é folga confortável.

O replay de um evento sai com o mesmo Webhook-Id do original, de propósito: replay é o mesmo fato, não um novo. Com a receita acima, o replay de um webhook já processado é ignorado, que é o certo.

Se o que se quer é reprocessar (a primeira passada processou errado), apague o registro daquele Webhook-Id da sua tabela antes de pedir o replay.

Use o relógio do evento, não o da chegada. O corpo do envio carrega createdAt, o instante em que o evento foi publicado. É esse campo que ordena, e ele é o mesmo em todas as tentativas.

Escreva transições que não retrocedem. Em vez de “aplique esta mudança”, pergunte “este é o estado mais recente que eu conheço?”:

def receber(evento: dict) -> None:
pedido = repositorio.buscar(evento["data"]["pedidoId"])
# Um evento mais velho que o estado atual não retrocede nada.
if pedido.atualizado_em >= parse(evento["createdAt"]):
return
pedido.aplicar(evento)