Skip to content

Reagenda entrega de webhook quando a falha é temporária - #86

Merged
Hirley merged 1 commit into
mainfrom
claude-hirley/webhook-retry
Aug 31, 2026
Merged

Hirley merged 1 commit into
mainfrom
claude-hirley/webhook-retry

Conversation

@Hirley

@Hirley Hirley commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Fecha #76.

WebhookDeliveryJob chamava WebhookDelivery.entregar e descartava o retorno. Como o serviço capturava tudo, logava e devolvia false, o Active Job dava a entrega por concluída em qualquer cenário — endpoint fora do ar, timeout, 5xx. Tudo virava um aviso no log que ninguém reprocessava.

O retorno passa a ser a decisão

Situação Resultado O job…
2xx true encerra
assinatura pausada/excluída, URL reprovada na checagem de SSRF, 4xx, erro de TLS false encerra sem gastar tentativa
erro de rede, 429, 5xx levanta FalhaTemporaria reagenda, backoff polinomial, 5 tentativas

FalhaTemporaria é a única exceção que escapa do serviço, de propósito — é exatamente o sinal que o retry_on observa.

Três decisões que valem explicar

Erro de TLS não repete. Certificado expirado, inválido ou com nome errado é configuração do outro lado. Repetir cinco vezes não conserta e mantém uma assinatura quebrada parecendo viva. Fica de fora de ERROS_TEMPORARIOS deliberadamente, com o motivo no código.

Erro inesperado também não repete. Se algo estourar que não seja instabilidade do outro lado, é bug nosso — repetir não conserta e atrasa o diagnóstico. Vai para o log em nível error (antes era warn) e encerra.

O bloco do retry_on existe porque a alternativa é silêncio. Sem ele, esgotadas as tentativas a entrega sumiria sem rastro — o mesmo problema desta issue, só que adiado da primeira falha para a quinta.

Uma sutileza que quase reintroduziu o bug

O rescue FalhaTemporaria precisa vir antes do rescue StandardError. Como ela herda dele, sem essa cláusula a exceção levantada na avaliação da resposta seria engolida pelo rescue genérico e viraria de novo o false silencioso que este PR elimina. Está comentado no código, porque é o tipo de linha que alguém remove achando redundante.

Mudança de ambiente que veio junto

config.active_job.queue_adapter = :test no ambiente de teste. Sem isso valia o :async, que roda o job de verdade numa thread de fundo durante o teste — contra o mesmo banco, dentro de uma transação que o RSpec vai desfazer, e com chance de tentar rede real via WebhookDelivery.

Hoje isso quase nunca disparava, porque os specs de dispatcher dublam perform_later. Mas era acidente, não desenho. E é o adapter :test que permite verificar o reagendamento sem esperar backoff real.

O que este PR não resolve

O adapter em produção continua sendo o :async em memória, então as tentativas reagendadas também se perdem num restart — é a #77. O retry aqui cobre a instabilidade curta, que é a maioria dos casos, e vale independentemente do backend: mesmo com Solid Queue, um job que nunca levanta exceção nunca seria repetido. Registrado no README.

Verificação

resultado
RuboCop 105 arquivos, 0 offenses
Zeitwerk check All is good!
RSpec 375 exemplos, 0 falhas (eram 366; 9 novos)

Cobertura nova: cada classificação de falha verificada isoladamente (rede, timeout, 5xx, 429, TLS, erro inesperado) e as três decisões do job (reagenda no temporário, não reagenda no definitivo nem no sucesso).

Resolvi as duas ofensas de RuboCop que a mudança gerou sem abrir exceção no .rubocop.yml: Class.new(StandardError) virou declaração de classe, e o método que avalia a resposta passou a se chamar entregue?, que é o que ele de fato responde.

🤖 Generated with Claude Code

Fecha #76.

WebhookDeliveryJob chamava WebhookDelivery.entregar e descartava o
retorno. Como o serviço capturava tudo, logava e devolvia false, o
ActiveJob dava a entrega por concluída em qualquer cenário: endpoint
fora do ar, timeout, 5xx — tudo virava um aviso no log que ninguém
reprocessava.

O retorno de #entregar passa a ser a decisão:

  * true  — entregue (2xx);
  * false — recusa definitiva (assinatura pausada ou excluída, URL
    reprovada na checagem de SSRF, 4xx, erro de TLS): o job encerra sem
    gastar tentativa, porque repetir não mudaria a resposta;
  * FalhaTemporaria — erro de rede, 429 ou 5xx: única exceção que escapa
    de propósito, e é o que o retry_on do job observa.

Cinco tentativas com backoff polinomial. O bloco do retry_on registra a
desistência em nível error — sem ele, a entrega perdida voltaria a sumir
em silêncio, só que na quinta falha em vez da primeira.

Duas classificações que não são óbvias, e por isso estão comentadas no
código: erro de TLS não repete (certificado errado é configuração do
outro lado, e repetir mantém uma assinatura quebrada parecendo viva), e
erro inesperado também não (é bug nosso; repetir não conserta e atrasa o
diagnóstico).

O rescue de FalhaTemporaria precisa vir antes do rescue de
StandardError: como ela herda dele, sem essa cláusula a exceção seria
engolida e viraria de novo o false silencioso que esta mudança elimina.

Junto vem config.active_job.queue_adapter = :test no ambiente de teste.
Sem isso valia o :async, que roda o job de verdade numa thread de fundo
durante o teste — contra o mesmo banco, dentro de uma transação que o
RSpec desfaz, e com chance de tentar rede. Hoje quase nunca disparava
porque os specs dublam perform_later, mas isso era acidente, não
desenho; e é o adapter :test que permite verificar o retry sem esperar
backoff real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Hirley
Hirley merged commit 4eddd89 into main Aug 31, 2026
3 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in @Hirley's task_keeper_api Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant