Como evitar retry storms em background jobs
API externa caiu. 100k jobs no Sidekiq decidem tentar de novo ao mesmo tempo. Jitter, circuit breaker, DLQ — retry não é fix, é estratégia de adiamento.
Como evitar retry storms em background jobs
Sua API externa caiu por 5 minutos.
Quando ela volta, 100 mil jobs batem nela ao mesmo tempo.
Ela cai de novo. Agora por sua culpa.
Bem-vindo ao retry storm.
O default do Sidekiq que ninguém leu
Sidekiq, por padrão, tenta de novo 25 vezes.
A fórmula é mais ou menos assim:
# polynomial backoff padrão do Sidekiq
(count ** 4) + 15 + (rand(30) * (count + 1))
Tradução:
- retry 1: ~30s
- retry 5: ~10min
- retry 10: ~3h
- retry 25: ~21 dias
Parece ok. Não é.
O problema não é o intervalo. É o volume sincronizado.
O que é um retry storm
Cenário real:
12:00:00 — API externa começa a retornar 500
12:00:00 — 100.000 jobs estavam rodando, todos falham
12:00:30 — todos os 100.000 entram em retry simultâneo
12:00:30 — API volta, mas leva 100k requests em 1 segundo
12:00:31 — API cai de novo
12:01:00 — segundo retry, +100k batendo
...
Você não está retentando. Está fazendo DDoS na sua própria dependência.
E o pior: a API caiu uma vez por 5 minutos. Você fez ela ficar instável por 2 horas.
Por que isso acontece
Dois motivos.
1. Jitter zero.
Se 100k jobs falharam no mesmo segundo, todos vão tentar de novo no mesmo segundo.
Backoff exponencial sem jitter = sincronização perfeita.
2. Ninguém olhou os defaults.
O default do Sidekiq foi pensado pra UM job que falha. Não pra cem mil.
Em escala, default vira bomba.
Backoff com jitter — o que importa
Sem jitter:
retry 1: todos em 30s
retry 2: todos em 16min
retry 3: todos em 1h21
Picos sincronizados. Storm garantido.
Full jitter
sleep_time = rand(0..base_backoff(count))
Cada job dorme entre 0 e o backoff calculado. Espalha bonito.
Decorrelated jitter (AWS Architecture Blog)
sleep_time = [cap, rand(base..(previous_sleep * 3))].min
Cresce mais rápido quando o sistema está saudável e não sincroniza. É o que a AWS recomenda.
No Sidekiq:
class FlakyJob
include Sidekiq::Job
sidekiq_options retry: 10
sidekiq_retry_in do |count, _exception|
base = (count ** 4) + 15
rand(base..(base * 3)) # jitter decorrelacionado
end
def perform(id)
ExternalAPI.call(id)
end
end
Pequeno. Salva sua vida.
Circuit breaker — pare antes de bater
Retry é cego.
Se a API está fora, retentar não ajuda. Só piora.
A solução adulta é circuit breaker:
require 'circuitbox'
class ExternalAPI
def self.call(id)
circuit = Circuitbox.circuit(:external_api, {
exceptions: [Net::OpenTimeout, Net::ReadTimeout],
volume_threshold: 10,
error_threshold: 50,
time_window: 60,
sleep_window: 300
})
circuit.run do
HTTP.timeout(5).get("https://api.exemplo.com/#{id}")
end
end
end
Quando o breaker abre:
- novos jobs falham instantaneamente
- não bate na API
- depois de
sleep_window, deixa passar um pra testar
A diferença:
- sem breaker: 100k retries inúteis batendo numa API morta
- com breaker: 100k jobs falham em milissegundos, vão pra fila de retry com calma
O custo de retentar sem pensar
Conta real que vi:
- 1 job falhando
- 25 retries default
- ~21 dias de tentativas
- cada tentativa: HTTP call de 5s + escrita no Redis
Por job: 125s de CPU, 25 round-trips ao Redis, 25 chamadas externas.
Multiplica por 100k jobs falhos = 3.5 milhões de chamadas inúteis distribuídas em 3 semanas.
Sua conta da Datadog ama isso.
Quando retry piora as coisas
Casos clássicos de cascading failure:
1. Dependência lenta, não morta.
API responde em 30s em vez de 200ms. Seus workers travam aguardando. Fila enche. Retries enfileiram em cima. Você perde toda a capacidade de processamento, não só os jobs daquela API.
2. Job não idempotente.
Retry cobra o cartão duas vezes. Manda email três. Cria pedido duplicado.
Retry sem idempotência é bug com agenda.
3. Job que escreve no recurso que está caindo.
Banco com lock contention? Você retenta e empilha mais lock. Banco morre mais rápido.
4. Fan-out de retries.
Job A falha, retenta. Cada retry dispara 10 jobs B. Que também falham. Que também retentam. Que disparam 100 jobs C.
Em 3 níveis você tem 1000x o volume original. Storm exponencial.
Dead letter queue — onde os mortos descansam
Em algum momento, parar.
class PaymentJob
include Sidekiq::Job
sidekiq_options retry: 5,
dead: true # vai pro morgue depois de 5 falhas
sidekiq_retries_exhausted do |msg, ex|
DeadLetterQueue.push(
job_class: msg['class'],
args: msg['args'],
error: ex.message,
failed_at: Time.now
)
Alerting.notify("Job #{msg['class']} morto após 5 tentativas")
end
def perform(payment_id)
PaymentProcessor.charge(payment_id)
end
end
DLQ não é fila de descarte. É fila de investigação.
Diferença entre dev senior e junior:
- junior coloca
retry: 25e esquece - senior coloca
retry: 5+ DLQ + alerta
Um aprende quando algo quebra. O outro descobre 21 dias depois, quando o cliente liga.
A arquitetura completa
Job falha
↓
Erro é "retryable"? (timeout, 5xx)
↓ sim ↓ não
Circuit breaker aberto? Vai direto pra DLQ
↓ não ↓ sim
Reenfileira Falha rápido (sem chamar API)
com backoff
+ jitter
↓
Excedeu max retries?
↓ sim
Vai pra DLQ
↓
Alerta
↓
Humano investiga
Cada caixa é uma decisão consciente. Não default.
Limites de concorrência salvam vidas
Mesmo com jitter, se você tem 50k jobs prontos pra rodar e 200 workers, ainda bate forte.
Limite explícito por fila:
# config/sidekiq.yml
:limits:
external_api: 20
payments: 10
emails: 50
Ou com sidekiq-throttled:
class ExternalAPIJob
include Sidekiq::Job
include Sidekiq::Throttled::Job
sidekiq_throttle(
concurrency: { limit: 10 },
threshold: { limit: 100, period: 1.minute }
)
end
100 chamadas por minuto. Não importa quantos jobs estão na fila.
A API agradece.
Números reais que mudei na vida
Antes:
- Sidekiq default (
retry: 25) - sem jitter
- sem circuit breaker
- API externa caía 1x/semana
- a cada queda, 4h de instabilidade total no sistema
Depois:
retry: 5+ decorrelated jitter- circuitbox com sleep_window 5min
- throttle: 30 req/min para API externa
- DLQ + alerta
Mesma queda da API:
- 30s de erro
- jobs vão pra DLQ se a API ficar fora >5min
- sistema continua processando outras filas normalmente
Custo de chamada externa caiu 70%. Páginas no on-call caíram 90%.
Conclusão
Retry parece grátis. Não é.
Cada retry: 25 no seu código é uma promessa de DDoS contra suas dependências.
Se você não pensou em jitter, circuit breaker, idempotência e DLQ, você não tem retry — tem bomba relógio.
A regra de ouro:
Retry não é conserto. Retry é estratégia de adiar o problema.
Conserto é entender por que falhou.
Se você só retenta, está empurrando a falha pro futuro, geralmente multiplicada.
Sistemas resilientes não retentam mais. Retentam melhor, e param na hora certa.
