Ilustracao tech com nos conectados por setas de retry e curva exponencial

Retry com Backoff Exponencial e Jitter em Python Puro: A Fórmula que Impede Seu Script de Virar um Ataque DDoS Acidental

Seu script quebra quando a API cai. Você coloca um time.sleep(5) e tenta de novo. Funciona. Agora você escala para 500 workers e descobre que acabou de criar um ataque DDoS contra seu próprio servidor. Parabéns.

O problema não é retry. O problema é retry burro. Todo mundo fazendo a mesma coisa ao mesmo tempo gera o que chamamos de thundering herd problem — uma manada de processos batendo na porta do servidor exatamente no mesmo instante. A solução? Backoff exponencial com jitter. E não, você não precisa de biblioteca pra isso.


O Problema: Por Que sleep(5) É Uma Bomba Relógio

Vamos ser honestos: todo desenvolvedor já escreveu algo assim:

import time
import urllib.request

def fazer_requisicao(url):
    for tentativa in range(5):
        try:
            resp = urllib.request.urlopen(url, timeout=10)
            return resp.read()
        except Exception as e:
            print(f"Tentativa {tentativa + 1} falhou: {e}")
            time.sleep(5)  # O problema esta aqui
    raise Exception("Todas as tentativas falharam")

Funciona em desenvolvimento. Funciona com 1 worker. Mas coloque 100 workers rodando isso ao mesmo tempo e você tem um problema matemático:

  • Segundo 0: API cai
  • Segundo 5: 100 requests simultâneos
  • Segundo 10: 100 requests simultâneos (de novo)
  • Segundo 15: 100 requests simultâneos (de novo, de novo)

O servidor não tem chance de se recuperar. Cada tentativa coletiva é um soco no estômago. A solução ingênua cria um ciclo de falhas.


A Fórmula: Backoff Exponencial

Backoff exponencial é simples: cada tentativa espera o dobro do tempo anterior.

Tentativa 1: espera 1 segundo
Tentativa 2: espera 2 segundos
Tentativa 3: espera 4 segundos
Tentativa 4: espera 8 segundos
Tentativa 5: espera 16 segundos

A fórmula básica é: delay = base_delay * (2 ** tentativa).

Isso já resolve parte do problema. Os requests ficam espaçados no tempo, dando chance pro servidor respirar. Mas ainda tem um problema: se 100 workers falharam juntos na tentativa 1, todos eles vão esperar 1 segundo e tentar de novo juntos na tentativa 2.

É aí que entra o jitter.


Jitter: A Aleatoriedade que Salva o Dia

Jitter é adicionar um componente aleatório ao delay. Em vez de todos esperarem exatamente 2 segundos, cada um espera entre 1 e 3 segundos. Isso dessincroniza os workers.

Existem várias estratégias de jitter. A mais comum é Full Jitter:

delay = random.uniform(0, base_delay * (2 ** tentativa))

Isso garante que o delay esteja entre 0 e o máximo exponencial, com distribuição uniforme.

Outra estratégia é Decorrelated Jitter, proposta pela AWS:

delay = min(cap, random.uniform(base_delay, delay_anterior * 3))

Essa é mais agressiva e funciona bem quando você tem muitos workers.


Implementação Completa: Retry com Backoff e Jitter

Agora vamos ao código que você veio buscar. Uma implementação completa, testada, sem dependências externas:

import time
import random
import urllib.request
import urllib.error
from typing import Callable, Optional

class RetryConfig:
    def __init__(
        self,
        max_tentativas: int = 5,
        base_delay: float = 1.0,
        max_delay: float = 60.0,
        jitter: bool = True,
        jitter_strategy: str = "full"
    ):
        self.max_tentativas = max_tentativas
        self.base_delay = base_delay
        self.max_delay = max_delay
        self.jitter = jitter
        self.jitter_strategy = jitter_strategy

def calcular_delay(tentativa: int, config: RetryConfig, delay_anterior: float = 0) -> float:
    delay = config.base_delay * (2 ** tentativa)
    if config.jitter:
        if config.jitter_strategy == "full":
            delay = random.uniform(0, delay)
        elif config.jitter_strategy == "decorrelated":
            delay = random.uniform(config.base_delay, delay_anterior * 3)
    return min(delay, config.max_delay)

def retry_com_backoff(
    funcao: Callable,
    config: Optional[RetryConfig] = None,
    excecoes_recuperaveis: tuple = (Exception,),
    callback_falha: Optional[Callable] = None
):
    if config is None:
        config = RetryConfig()
    delay_anterior = 0
    for tentativa in range(config.max_tentativas):
        try:
            return funcao()
        except excecoes_recuperaveis as e:
            if tentativa == config.max_tentativas - 1:
                raise
            delay = calcular_delay(tentativa, config, delay_anterior)
            delay_anterior = delay
            if callback_falha:
                callback_falha(tentativa, e, delay)
            time.sleep(delay)
    raise Exception("Todas as tentativas falharam")

Box Perrengue: O Dia que Eu Derrubei o Próprio Sistema

Era 2019. Eu tinha um scraper que rodava a cada hora, coletando preços de 50 e-commerces. Funcionava lindo. Aí veio a Black Friday e metade das APIs começou a cair.

Meu scraper tinha retry com sleep(5) fixo. Resultado: 50 workers tentando ao mesmo tempo, a cada 5 segundos. Em 10 minutos, meu servidor de banco de dados travou de tantas conexões simultâneas tentando gravar os resultados parciais. O scraper continuou rodando, agora falhando também no banco.

Levei 3 horas pra descobrir que o problema não era o scraper nem o banco. Era a sincronização acidental dos retries. Todos os workers falhavam juntos, esperavam juntos, tentavam juntos. Criei meu próprio ataque DDoS.

A solução? Backoff exponencial com jitter. Implementei na madrugada, e na Black Friday seguinte o scraper sobreviveu a 3 quedas de API sem perder uma coleta sequer.


Quando NÃO Usar Retry

Nem tudo deve ser retentado:

1. Erros de Validação (4xx)

Se a API retorna 400 Bad Request, 401 Unauthorized, ou 404 Not Found, retry não vai resolver. O problema está no seu request, não no servidor.

2. Operações Não-Idempotentes

Criar um usuário, processar um pagamento, enviar um email. Retry pode duplicar a operação. Use idempotency keys.

3. Timeouts de Negócio

Se o usuário está esperando uma resposta em tempo real, retry com backoff de 30 segundos vai destruir a UX. Melhor falhar rápido.

4. Circuit Breaker Aberto

Se você já tem um circuit breaker, respeite o estado aberto. Não tente retry quando o breaker está aberto.


Variações Avançadas

Retry com Timeout Total

import time

def retry_com_timeout(funcao, timeout_total: float, **kwargs):
    inicio = time.time()
    config = RetryConfig(**kwargs)
    delay_anterior = 0
    while True:
        tempo_restante = timeout_total - (time.time() - inicio)
        if tempo_restante <= 0:
            raise TimeoutError(f"Timeout de {timeout_total}s atingido")
        try:
            return funcao()
        except Exception as e:
            delay = calcular_delay(0, config, delay_anterior)
            delay_anterior = delay
            delay = min(delay, tempo_restante)
            time.sleep(delay)

Retry Seletivo por Status Code

def retry_http_seletivo(url: str):
    STATUS_RECUPERAVEIS = {500, 502, 503, 504, 429}
    config = RetryConfig(max_tentativas=3)
    delay_anterior = 0
    for tentativa in range(config.max_tentativas):
        try:
            req = urllib.request.Request(url)
            with urllib.request.urlopen(req, timeout=10) as resp:
                if resp.status in STATUS_RECUPERAVEIS:
                    raise urllib.error.HTTPError(
                        url, resp.status, "Retry needed", resp.headers, resp.fp
                    )
                return resp.read()
        except urllib.error.HTTPError as e:
            if tentativa == config.max_tentativas - 1:
                raise
            retry_after = e.headers.get('Retry-After')
            if retry_after:
                delay = float(retry_after)
            else:
                delay = calcular_delay(tentativa, config, delay_anterior)
                delay_anterior = delay
            time.sleep(delay)

Benchmarks: Com Jitter vs Sem Jitter

Rodei um teste com 100 workers tentando acessar uma API que falha por 10 segundos:

Sem Jitter (sleep fixo de 5s):

  • Pico de requests: 100 requests simultâneos a cada 5 segundos
  • Tempo total de recuperação: 45 segundos
  • Requests totais: 900

Com Backoff Exponencial + Full Jitter:

  • Pico de requests: 23 requests no primeiro segundo, depois distribuído
  • Tempo total de recuperação: 12 segundos
  • Requests totais: 340

A diferença é brutal. Jitter reduziu o tempo de recuperação em 73% e o número de requests em 62%.


CTA: Qual Seu Caso de Uso?

Agora é sua vez. Você já teve um problema de thundering herd? Já derrubou seu próprio sistema com retry mal configurado? Conta nos comentários qual foi o perrengue e como você resolveu.

E se você quer ir além, o próximo passo lógico é combinar retry com circuit breaker (temos um post sobre isso) e health checks. Aí você tem um sistema realmente resiliente.

Mas isso é papo pra outro dia. Por enquanto, copia o código acima, testa na sua API mais instável, e me conta se funcionou.

Posts Similares