Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,17 @@ Além do Telegram, o **admin** pode cadastrar webhooks genéricos em `/webhooks`

- `WebhookSubscription` (`app/models/webhook_subscription.rb`) guarda a URL e os eventos escolhidos (array nativo do Postgres). Um webhook pode ser pausado (`active: false`) sem precisar excluir o cadastro.
- `WebhookDispatcher` (`app/services/webhook_dispatcher.rb`) encontra as assinaturas ativas que escutam o evento e enfileira `WebhookDeliveryJob` pra cada uma — em background (adapter `:async` padrão do Rails; este projeto não tem Sidekiq/Solid Queue configurado), pra não travar a request original no tempo de resposta de um serviço de terceiro.
- `WebhookDelivery` (`app/services/webhook_delivery.rb`) faz o `POST` de fato, com timeout curto (5s). Um endpoint de terceiro fora do ar, lento ou respondendo erro não derruba nada — só loga e segue; não há retry.
- `WebhookDelivery` (`app/services/webhook_delivery.rb`) faz o `POST` de fato, com timeout curto (5s). O retorno dele é o que decide se o job tenta de novo, e a distinção é deliberada:

| Situação | Resultado | O job… |
|---|---|---|
| `2xx` | `true` | encerra |
| assinatura pausada ou excluída, URL reprovada na checagem de SSRF, resposta `4xx`, erro de TLS | `false` | encerra **sem gastar tentativa** — repetir não mudaria a resposta |
| erro de rede (conexão recusada, timeout, DNS), `429`, `5xx` | levanta `FalhaTemporaria` | reagenda, com backoff, até 5 tentativas |

Esgotadas as tentativas, `WebhookDeliveryJob` registra a desistência no log em nível `error` — a entrega perdida deixa rastro em vez de sumir. Um erro inesperado (bug nosso, não instabilidade do outro lado) também não consome tentativas: é logado como `error` e encerra, porque repetir cinco vezes não conserta e só atrasa o diagnóstico.

Vale a ressalva: como o adapter ainda é o `:async` em memória, as tentativas reagendadas também se perdem num restart do processo. O retry cobre a instabilidade curta, que é a maioria; durabilidade de verdade depende de trocar o backend da fila.
- **Proteção contra SSRF**: `PublicHttpTarget` (`app/services/public_http_target.rb`) resolve o host e recusa endereços de rede privada/local (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16` e as faixas IPv6 equivalentes), pra que um webhook não vire um jeito de fazer a aplicação bater num serviço interno da própria rede. A checagem roda **duas vezes, de propósito**:
- no **cadastro/edição** (validação de `WebhookSubscription`), só pra dar o erro no formulário enquanto o admin ainda está na tela;
- de novo na **hora da entrega** (`WebhookDelivery`), imediatamente antes de conectar — e é essa que protege de fato. Sozinha, a validação do cadastro não segura *DNS rebinding*: como a entrega acontece quando um evento dispara (possivelmente dias depois), bastaria cadastrar um host que resolve pra um IP público e trocar o registro DNS com calma. O IP verificado na entrega é passado direto pro `Net::HTTP` (`ipaddr:`), então o endereço checado é exatamente o endereço conectado — sem intervalo entre a checagem e o uso. O host original continua valendo pro cabeçalho `Host`, SNI e validação do certificado TLS.
Expand Down
35 changes: 29 additions & 6 deletions app/jobs/webhook_delivery_job.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
# frozen_string_literal: true

# Roda em background (adapter padrão do Rails, :async — não há
# Sidekiq/Solid Queue configurado neste projeto; jobs em andamento se
# perdem se o processo reiniciar, aceitável pro porte atual) pra não
# travar a request original no tempo de resposta de um serviço de
# terceiro. Ver WebhookDispatcher (quem enfileira) e WebhookDelivery
# (quem sabe fazer o POST de fato).
# Roda em background pra não travar a request original no tempo de
# resposta de um serviço de terceiro. Ver WebhookDispatcher (quem
# enfileira) e WebhookDelivery (quem sabe fazer o POST de fato).
#
# O adapter ainda é o padrão do Rails (:async), que guarda a fila na
# memória do processo web — jobs pendentes se perdem num restart, e as
# tentativas reagendadas abaixo também. Trocar por um backend persistente
# é assunto de outra issue; o retry aqui é útil de qualquer forma, porque
# a maioria das instabilidades de rede passa em segundos, muito antes de
# um deploy.
class WebhookDeliveryJob < ApplicationJob
queue_as :default

TENTATIVAS = 5

# Só WebhookDelivery::FalhaTemporaria reagenda. Uma recusa definitiva
# (assinatura pausada, URL reprovada na checagem de SSRF, 4xx) volta
# como `false` e encerra sem gastar tentativa — ver o contrato de
# retorno documentado em WebhookDelivery#entregar.
#
# O bloco roda quando as tentativas acabam. Sem ele, a desistência
# seria silenciosa, que é exatamente o problema que este retry veio
# resolver — só que adiado para a quinta falha em vez da primeira.
retry_on WebhookDelivery::FalhaTemporaria, wait: :polynomially_longer, attempts: TENTATIVAS do |job, erro|
subscription_id, event = job.arguments

Rails.logger.error(
"[WebhookDeliveryJob] desisti de entregar \"#{event}\" pra assinatura #{subscription_id} " \
"depois de #{TENTATIVAS} tentativas: #{erro.message}"
)
end

def perform(subscription_id, event, payload)
subscription = WebhookSubscription.find_by(id: subscription_id)
WebhookDelivery.entregar(subscription, event, payload)
Expand Down
68 changes: 60 additions & 8 deletions app/services/webhook_delivery.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@
# evita depender de uma gem de mock de rede — o Gemfile não tem
# WebMock/VCR — e evita qualquer chamada de rede real nos testes).
class WebhookDelivery
# Única exceção que escapa daqui de propósito. É o sinal que faz
# WebhookDeliveryJob reagendar a entrega (ver o retry_on lá).
class FalhaTemporaria < StandardError; end

# Erros de rede que costumam passar sozinhos: o endpoint pode estar
# reiniciando, o DNS oscilando, a rota caindo por um instante.
#
# Erro de TLS fica de fora de propósito. Certificado inválido, expirado
# ou com nome errado é configuração do outro lado — repetir cinco vezes
# não conserta, só adia o diagnóstico e mantém uma assinatura quebrada
# parecendo viva.
ERROS_TEMPORARIOS = [
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, EOFError, SocketError
].freeze

# +ipaddr+ é o endereço já verificado por PublicHttpTarget. Passar ele
# pro Net::HTTP faz a conexão ir direto nesse IP, sem resolver o host de
# novo — é o que impede que o DNS mude entre a checagem e a conexão
Expand All @@ -33,11 +49,18 @@ def initialize(transport: DEFAULT_TRANSPORT)
@transport = transport
end

# Não levanta exceção pra fora — um endpoint de terceiro fora do ar,
# lento ou respondendo erro não pode derrubar o job (nem, se algum dia a
# entrega virar síncrona, a request que originou o evento). Loga e
# retorna false; não há retry (fora do escopo deste MVP — ver issue de
# webhooks de saída no board).
# O retorno é o que decide se o job tenta de novo:
#
# * +true+ — entregue (2xx);
# * +false+ — recusa definitiva: assinatura inexistente ou pausada,
# URL reprovada na checagem de SSRF, ou resposta 4xx. Tentar de novo
# não mudaria nada, então o job encerra sem gastar tentativa;
# * +FalhaTemporaria+ — erro de rede, 429 ou 5xx.
#
# Antes, TODA falha virava +false+ e o ActiveJob dava a entrega por
# concluída: um endpoint fora do ar sumia com um aviso no log e ninguém
# reprocessava. A distinção acima existe pra que só o caso que vale a
# pena repetir chegue ao retry_on.
def entregar(subscription, event, payload)
return false unless subscription&.active?

Expand All @@ -53,15 +76,44 @@ def entregar(subscription, event, payload)
end

body = { event: event, occurred_at: Time.current.iso8601, data: payload }.to_json
response = @transport.call(alvo.uri, body, alvo.ip)
response.is_a?(Net::HTTPSuccess)
entregue?(subscription, event, @transport.call(alvo.uri, body, alvo.ip))
rescue FalhaTemporaria
# Precisa vir ANTES do rescue de StandardError: FalhaTemporaria herda
# dele, e sem esta cláusula a exceção levantada por #entregue? seria
# engolida logo abaixo — virando de novo o `false` silencioso que
# esta classe deixou de produzir.
raise
rescue *ERROS_TEMPORARIOS => e
raise FalhaTemporaria, "#{subscription.url}: #{e.class} #{e.message}"
rescue StandardError => e
Rails.logger.warn("[WebhookDelivery] falha ao entregar \"#{event}\" pra #{subscription&.url}: #{e.class} #{e.message}")
# Erro inesperado aqui é bug nosso, não instabilidade do outro lado.
# Repetir não conserta e só atrasa o diagnóstico — loga alto e
# encerra, sem consumir tentativa.
Rails.logger.error(
"[WebhookDelivery] erro inesperado ao entregar \"#{event}\" pra #{subscription&.url}: #{e.class} #{e.message}"
)
false
end

private

def entregue?(subscription, event, response)
return true if response.is_a?(Net::HTTPSuccess)
raise FalhaTemporaria, "#{subscription.url} respondeu #{response.class}" if temporaria?(response)

Rails.logger.warn(
"[WebhookDelivery] entrega de \"#{event}\" pra #{subscription.url} recusada com #{response.class}"
)
false
end

# 5xx é problema do servidor do outro lado; 429 é ele pedindo pra
# esperar. Os dois merecem outra tentativa. Um 4xx qualquer, não: o
# payload ou a rota é que estão errados, e repetir só gera ruído.
def temporaria?(response)
response.is_a?(Net::HTTPServerError) || response.is_a?(Net::HTTPTooManyRequests)
end

# Só registra o motivo — quem chama é que decide desistir da entrega. O
# `return false` fica lá em cima, visível junto do `unless`, em vez de
# escondido no fim de um método cujo nome não deixa claro que ele
Expand Down
9 changes: 9 additions & 0 deletions config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@

config.active_storage.service = :test

# Sem isto valeria o default do Rails, :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 (no caso de WebhookDelivery)
# com chance de tentar rede de verdade. Hoje isso quase nunca dispara,
# porque os specs dublam WebhookDeliveryJob.perform_later, mas é
# acidente e não desenho. O adapter :test também é o que permite
# verificar o retry de WebhookDeliveryJob sem esperar backoff real.
config.active_job.queue_adapter = :test

config.action_mailer.perform_caching = false
config.action_mailer.delivery_method = :test
# Necessário pra gerar URLs fora de uma requisição (o e-mail de
Expand Down
30 changes: 30 additions & 0 deletions spec/jobs/webhook_delivery_job_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,34 @@
expect(WebhookDelivery).to have_received(:entregar).with(nil, 'demanda_criada', { id: 1 })
end
end

# Ver o retry_on em WebhookDeliveryJob e o contrato de retorno de
# WebhookDelivery#entregar. O que se verifica aqui é a decisão do job:
# reagendar só quando vale a pena, sem gastar tentativa numa recusa que
# não vai mudar de resposta.
describe 'retentativa' do
let(:subscription) { create(:webhook_subscription) }
let(:fila) { ActiveJob::Base.queue_adapter.enqueued_jobs }

it 'reagenda quando a entrega falha por algo temporário' do
allow(WebhookDelivery).to receive(:entregar).and_raise(WebhookDelivery::FalhaTemporaria, 'endpoint fora do ar')

expect { described_class.perform_now(subscription.id, 'demanda_criada', {}) }
.to change(fila, :size).by(1)
end

it 'não reagenda quando a recusa é definitiva' do
allow(WebhookDelivery).to receive(:entregar).and_return(false)

expect { described_class.perform_now(subscription.id, 'demanda_criada', {}) }
.not_to change(fila, :size)
end

it 'não reagenda quando a entrega dá certo' do
allow(WebhookDelivery).to receive(:entregar).and_return(true)

expect { described_class.perform_now(subscription.id, 'demanda_criada', {}) }
.not_to change(fila, :size)
end
end
end
43 changes: 40 additions & 3 deletions spec/services/webhook_delivery_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,47 @@
expect(chamadas).to be_empty
end

it 'não propaga exceção se o transporte falhar (ex.: endpoint fora do ar) e retorna false' do
delivery_com_erro = described_class.new(transport: ->(_uri, _body, _ip) { raise SocketError, 'falha de rede' })
# Antes, toda falha virava `false` e o ActiveJob dava a entrega por
# concluída. O que separa "vale tentar de novo" de "não adianta" é o
# tipo de retorno, e é ele que WebhookDeliveryJob usa pra decidir —
# por isso cada caso abaixo verifica exceção OU booleano, nunca os dois.
describe 'classificação da falha' do
def delivery_que(&bloco)
described_class.new(transport: ->(_uri, _body, _ip) { bloco.call })
end

it 'levanta FalhaTemporaria quando o endpoint está fora do ar' do
expect { delivery_que { raise SocketError, 'falha de rede' }.entregar(subscription, 'demanda_criada', {}) }
.to raise_error(WebhookDelivery::FalhaTemporaria, /SocketError/)
end

it 'levanta FalhaTemporaria quando a conexão estoura o tempo' do
expect { delivery_que { raise Net::ReadTimeout }.entregar(subscription, 'demanda_criada', {}) }
.to raise_error(WebhookDelivery::FalhaTemporaria)
end

it 'levanta FalhaTemporaria quando o endpoint responde 5xx' do
expect { delivery_que { Net::HTTPServiceUnavailable.allocate }.entregar(subscription, 'demanda_criada', {}) }
.to raise_error(WebhookDelivery::FalhaTemporaria)
end

it 'levanta FalhaTemporaria quando o endpoint pede pra esperar (429)' do
expect { delivery_que { Net::HTTPTooManyRequests.allocate }.entregar(subscription, 'demanda_criada', {}) }
.to raise_error(WebhookDelivery::FalhaTemporaria)
end

expect(delivery_com_erro.entregar(subscription, 'demanda_criada', {})).to be false
# Certificado inválido é configuração do outro lado, não
# instabilidade: repetir não conserta e mantém uma assinatura
# quebrada parecendo viva. Ver ERROS_TEMPORARIOS.
it 'não repete um erro de TLS' do
expect(delivery_que { raise OpenSSL::SSL::SSLError, 'certificado expirado' }
.entregar(subscription, 'demanda_criada', {})).to be false
end

it 'não repete um erro inesperado, que seria bug nosso' do
expect(delivery_que { raise ArgumentError, 'payload malformado' }
.entregar(subscription, 'demanda_criada', {})).to be false
end
end

# Ver PublicHttpTarget: a validação do cadastro sozinha não segura DNS
Expand Down
Loading