githublinkedinemail
← /articles

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.

5 mindistributed

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: 25 e 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.

Como evitar retry storms em background jobs
Como evitar retry storms em background jobs