Retry com backoff, idempotência no receptor e dead letter queue: o que aprendi montando o sistema de webhooks do Axxos CRM depois de perder eventos em produção.

A primeira versão dos webhooks do Axxos CRM cabia em duas dezenas de linhas. Quando um lead mudava de etapa do funil, eu fazia um await axios.post(url) dentro do mesmo controller que processou a atualização. Funcionou por seis semanas. Depois um cliente perdeu três dias inteiros de integração porque o servidor dele ficou intermitentemente indisponível e meus eventos sumiram no ar — sem retry, sem log estruturado, sem nada.
Esse post é sobre o sistema que construí no lugar daquele await axios.post, e por que praticamente cada decisão foi consequência direta de uma dor que vivi.
O que quebrou na primeira versão
A versão ingênua quebrou de quatro formas diferentes nos primeiros dois meses. Quero que elas fiquem registradas porque cada uma corresponde a uma decisão de arquitetura na versão atual.
Primeiro, o endpoint do cliente caiu por seis horas durante uma migração de DNS dele. Eu mandei 1.200 eventos nesse intervalo. Todos retornaram timeout ou 503. Como o axios.post estava no caminho síncrono do controller, eu engoli a exceção para não derrubar o update do lead. Resultado: 1.200 eventos perdidos, sem registro de quais foram.
Segundo, o webhook ficou em pé mas demorando 8 segundos para responder. Como eu não tinha timeout configurado, requests do CRM começaram a empilhar, os pods ficaram com event loop travado e a latência do CRM inteiro foi para o espaço por uns 20 minutos. Um único cliente lento contaminou todo mundo.
Terceiro, descobri que vários endpoints dos clientes não eram idempotentes. Quando eu finalmente implementei retry, comecei a criar leads duplicados no CRM deles por causa de respostas 200 que chegavam depois do timeout — eu reentregava algo que tinha sido processado, eles processavam de novo.
Quarto, eu não tinha forma de responder a pergunta "que eventos esse cliente recebeu na última semana?". Quando alguém abria ticket de suporte falando de evento perdido, eu não tinha como confirmar nem negar.
Stack técnica
Postgres (tabela outbox)
Persiste o evento no mesmo commit da operação de negócio. Garante que se a operação salvou, o evento será entregue (transactional outbox pattern).
BullMQ + Redis
Worker assíncrono que lê da outbox e tenta entregar. Mantém estado de tentativas, backoff e prioridade fora do banco de dados principal.
Axios com timeout estrito
Cliente HTTP com timeout de 10s, sem keep-alive entre tentativas. Não deixa o worker ficar pendurado em endpoint lento.
DLQ (tabela webhook_failures)
Eventos que falharam após o último retry vão para uma tabela separada, com payload completo e histórico de tentativas. Permite reentrega manual e auditoria.
Transactional outbox: o pulo do gato
O erro mais comum em sistemas de webhooks que vi (e cometi) é tentar enviar o evento no momento em que a coisa acontece. Não importa se você usa await axios.post, se você manda para uma fila, se você publica em um event bus. Se o envio está fora da transação do banco, você tem dois cenários ruins: ou o evento sai mas a transação dá rollback (você mentiu para o cliente), ou a transação commita mas o envio falha (você perdeu o evento).
A solução que adotei é o padrão transactional outbox. No mesmo BEGIN/COMMIT em que eu salvo a mudança de etapa do lead, eu salvo uma linha na tabela outbox_events. Se a transação commita, o evento existe garantidamente. Se dá rollback, ele desaparece junto com o resto.
// src/leads/leads.service.ts
import { Injectable } from "@nestjs/common"
import { DataSource } from "typeorm"
@Injectable()
export class LeadsService {
constructor(private readonly dataSource: DataSource) {}
async moveStage(leadId: string, toStageId: string, actorId: string) {
return this.dataSource.transaction(async (tx) => {
// 1. Operação de negócio
const lead = await tx.getRepository(Lead).findOneByOrFail({ id: leadId })
const fromStageId = lead.stageId
lead.stageId = toStageId
await tx.getRepository(Lead).save(lead)
// 2. Evento na outbox, no MESMO commit
await tx.getRepository(OutboxEvent).insert({
aggregateId: leadId,
aggregateType: "lead",
eventType: "lead.stage_changed",
payload: {
leadId,
fromStageId,
toStageId,
actorId,
changedAt: new Date().toISOString(),
},
tenantId: lead.tenantId,
status: "pending",
})
return lead
})
}
}Um worker separado faz polling da tabela com SELECT ... FOR UPDATE SKIP LOCKED, marca como processing, tenta entregar, e atualiza o status. O SKIP LOCKED é o que permite escalar horizontalmente o worker sem dois processos pegarem o mesmo evento.
Retry com backoff: o que funciona e o que dá problema
Backoff exponencial parece simples até você implementar. As perguntas que tive que responder na prática foram: quantas tentativas? que intervalo entre elas? que erros justificam retry e quais não? como evitar tempestade quando o endpoint do cliente volta?
Para o Axxos CRM, defini o seguinte: até 8 tentativas, com intervalos de 30s, 1min, 2min, 5min, 15min, 1h, 6h e 24h. Isso dá uma janela total de aproximadamente 31 horas — tempo suficiente para um cliente perceber e arrumar um problema antes do evento ir para DLQ.
Erros que disparam retry: timeout, conexão recusada, DNS falhando, e qualquer 5xx do servidor. Erros que não disparam retry: 4xx (exceto 408 e 429). Se o cliente respondeu 400 ou 422, o payload está errado e mais tentativas não vão consertar. Vai direto para DLQ.
Adicionei jitter aleatório de ±20% em cada intervalo. Sem jitter, se mil eventos falharam ao mesmo tempo porque o endpoint do cliente caiu, mil retries vão atingir o endpoint exatamente no mesmo segundo quando ele voltar — virando uma negação de serviço acidental por parte do meu próprio sistema.
// src/webhooks/delivery.worker.ts
const BACKOFF_MINUTES = [0.5, 1, 2, 5, 15, 60, 360, 1440]
function nextAttemptDelay(attempt: number): number {
const base = BACKOFF_MINUTES[Math.min(attempt, BACKOFF_MINUTES.length - 1)]
const jitter = base * (0.8 + Math.random() * 0.4) // ±20%
return jitter * 60 * 1000
}
function shouldRetry(error: AxiosError): boolean {
if (error.code === "ECONNABORTED") return true // timeout
if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") return true
const status = error.response?.status
if (!status) return true
if (status === 408 || status === 429) return true
return status >= 500 && status < 600
}Idempotência: a parte que protege o cliente
Retry sem idempotência é como dar duas vezes a mesma medicação porque o paciente não confirmou a primeira dose. O CRM pode ter mandado o evento, o cliente pode ter processado, mas se a resposta dele se perdeu na rede, eu vou reentregar. Se o endpoint dele cria um lead a cada chamada sem checar duplicata, vou criar leads em duplicidade.
A forma como atacamos isso foi com um header Idempotency-Key único por evento (UUID v4 gerado uma vez, na hora que o evento entra na outbox, e nunca muda entre retries). Documentamos para os clientes que devem manter um registro dos últimos 7 dias de keys recebidas e responder 200 OK sem reprocessar se receberem uma key já vista.
Para clientes que não querem implementar essa lógica, oferecemos um modo "at-most-once" — desligo o retry para o webhook deles. Aceitam perder eventos em troca de não receber duplicatas. É a escolha errada na maioria dos casos, mas existe gente que precisa.
Impacto em números
Antes da outbox: ~50/mês perdidos em incidentes
Endpoints intermitentes recuperam rápido
Antes: descobrir só quando cliente abria ticket
DLQ não é fim — é começo de uma conversa
A tabela webhook_failures recebe eventos que esgotaram todos os retries. Cada linha tem o payload original, todos os timestamps de tentativa, e o último erro retornado. Isso virou a base de três coisas: alerta automático para o time de suporte quando um cliente acumula mais de 10 falhas em uma hora, um painel interno onde o suporte vê o que falhou e por quê, e um botão de "reenviar" que move o evento de volta para a outbox.
O que aprendi é que DLQ funciona quando ela é operacionalizada. Uma tabela de eventos mortos que ninguém olha é só um cemitério caro. A regra que coloquei foi: nenhum evento fica em DLQ mais de 24h sem ser analisado. Se chegou lá, ou tem bug no nosso lado, ou tem problema crônico no cliente — qualquer um dos dois precisa de ação humana.
O que eu mudaria se começasse hoje
Duas coisas. A primeira é assinar os payloads com HMAC desde o início, não depois. Adicionei assinatura na v2, mas tive que coordenar migração com clientes que já estavam em produção. Doloroso e evitável.
A segunda é separar as filas por cliente. Hoje uma única fila atende todos os tenants, e um cliente com 50 mil eventos atrasados pode atrasar a entrega para todos os outros. A correção é simples (uma fila por tenant com prioridade), mas precisei refatorar o worker para suportar isso. Se eu tivesse pensado nisso desde o começo, teria saído de graça.
Tirando isso, o sistema está rodando há mais de um ano e a métrica que mais me orgulha não é nenhuma das de cima. É que parei de receber mensagem no WhatsApp de cliente perguntando "esse evento chegou para você?". Quando o sistema é bom, ele some.