Retry com Backoff Exponencial e Jitter em Python Puro: O Mecanismo Que Faz Sua Automacao Sobreviver a APIs Instaveis Sem Virar Um Ataque DDoS Acidental
Voce ja viu isso acontecer: sua automacao faz uma chamada pra uma API externa, recebe um erro 503, e simplesmente morre. Ou pior – entra em loop infinito tentando de novo a cada 100ms e acaba sendo bloqueada pelo rate limiter do servidor. Nos dois casos, voce perdeu.
O problema nao e o erro. APIs caem. Servidores reiniciam. Redes oscilam. O problema e como sua automacao reage quando isso acontece. E a resposta certa quase sempre e: tente de novo, espere mais a cada tentativa, e adicione aleatoriedade pra nao sincronizar com outros clientes.
Isso se chama Retry com Backoff Exponencial e Jitter. E o padrao usado por AWS, Google Cloud, Kubernetes, e basicamente todo sistema distribuido serio. E hoje voce vai implementar do zero, em Python puro, sem bibliotecas externas.
O Problema da Retentativa Ingenua
Antes de mostrar a solucao, vamos ver o que nao funciona. Aqui esta o retry que 90% dos desenvolvedores escrevem na pressa:
import urllib.request
import time
def fetch_ingenuo(url, max_retries=5):
"""Retry sem backoff - vai fritar o servidor."""
for tentativa in range(max_retries):
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.read()
except Exception as e:
print(f"Tentativa {tentativa + 1} falhou: {e}")
time.sleep(1) # Espera fixa de 1s - o problema esta aqui
raise RuntimeError(f"Falhou apos {max_retries} tentativas")
Funciona? Talvez. Mas tem tres problemas graves:
- Espera fixa: Se o servidor esta sobrecarregado, 100 clientes retryando a cada 1 segundo criam um pico sincronizado que impede a recuperacao.
- Sem jitter: Todos os clientes retryam ao mesmo tempo. Isso se chama “thundering herd” – e derruba servidores.
- Sem limite superior: Se voce escala pra 1000 workers, cada um retryando a cada 1s, voce e o ataque DDoS.
A Matematica do Backoff Exponencial
A ideia e simples: a cada tentativa, dobre o tempo de espera. Assim:
Tentativa 1: espera 1s
Tentativa 2: espera 2s
Tentativa 3: espera 4s
Tentativa 4: espera 8s
Tentativa 5: espera 16s
A formula e: delay = base_delay * 2^tentativa. Isso da tempo pro servidor se recuperar e reduz a pressao progressivamente.
Mas tem um problema: se 50 clientes comecam ao mesmo tempo, todos vao esperar 1s, depois 2s, depois 4s… e todos vao retryar nos mesmos instantes. E aqui que entra o jitter.
Jitter: A Aleatoriedade Que Salva Servidores
Jitter e simplesmente adicionar aleatoriedade ao delay. Em vez de esperar exatamente 4 segundos, voce espera algo entre 0 e 4 segundos. Isso dessincroniza os clientes e distribui a carga no tempo.
Existem duas estrategias principais:
- Full Jitter: delay = random(0, base * 2^tentativa). Totalmente aleatorio dentro do intervalo.
- Equal Jitter: delay = (base * 2^tentativa / 2) + random(0, base * 2^tentativa / 2). Metade fixa, metade aleatoria.
O Full Jitter funciona melhor na pratica porque maximiza a dessincronizacao. E o que a AWS recomenda no blog deles sobre arquitetura de sistemas distribuidos.
Implementacao Completa em Python Puro
Aqui esta a implementacao que eu uso em producao. Sem dependencias externas, so biblioteca padrao:
import urllib.request
import urllib.error
import time
import random
from typing import Optional, Tuple, Type
class RetryConfig:
"""Configuracao centralizada para retry com backoff exponencial."""
def __init__(
self,
max_retries: int = 5,
base_delay: float = 1.0,
max_delay: float = 60.0,
jitter: bool = True,
exponential_base: float = 2.0,
retryable_exceptions: Optional[Tuple[Type[Exception], ...]] = None,
retryable_status_codes: Optional[Tuple[int, ...]] = None,
):
self.max_retries = max_retries
self.base_delay = base_delay
self.max_delay = max_delay
self.jitter = jitter
self.exponential_base = exponential_base
self.retryable_exceptions = retryable_exceptions or (
urllib.error.URLError,
ConnectionError,
TimeoutError,
OSError,
)
self.retryable_status_codes = retryable_status_codes or (
429, # Too Many Requests
500, # Internal Server Error
502, # Bad Gateway
503, # Service Unavailable
504, # Gateway Timeout
)
def get_delay(self, attempt: int) -> float:
"""Calcula o delay com backoff exponencial + jitter."""
delay = self.base_delay * (self.exponential_base ** attempt)
delay = min(delay, self.max_delay)
if self.jitter:
delay = random.uniform(0, delay)
return delay
class RetryWithBackoff:
"""
Cliente HTTP com retry automatico usando backoff exponencial e jitter.
Usa apenas urllib da biblioteca padrao - zero dependencias externas.
"""
def __init__(self, config: Optional[RetryConfig] = None):
self.config = config or RetryConfig()
self.attempts_log = []
def _should_retry(self, exception, status_code) -> bool:
"""Decide se deve retryar baseado na excecao ou status code."""
if exception and isinstance(exception, self.config.retryable_exceptions):
return True
if status_code and status_code in self.config.retryable_status_codes:
return True
return False
def _log_attempt(self, attempt, success, delay, error):
"""Registra cada tentativa para observabilidade."""
entry = {
"attempt": attempt,
"success": success,
"delay_before": delay,
"error": error,
"timestamp": time.time(),
}
self.attempts_log.append(entry)
status = "sucesso" if success else error
print(f"[Retry] Tentativa {attempt}: {status} | delay: {delay:.2f}s")
def fetch(self, url, method="GET", headers=None,
data=None, timeout=10.0):
"""
Faz requisicao HTTP com retry automatico.
Returns: Tuple com (status_code, response_body, response_headers)
"""
last_exception = None
delay = 0.0
for attempt in range(self.config.max_retries + 1):
try:
req = urllib.request.Request(
url, method=method,
headers=headers or {}, data=data
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
status = resp.status
body = resp.read()
resp_headers = dict(resp.headers)
if self._should_retry(None, status):
if attempt < self.config.max_retries:
delay = self.config.get_delay(attempt)
self._log_attempt(
attempt + 1, False, delay,
f"status {status}"
)
time.sleep(delay)
continue
self._log_attempt(attempt + 1, True, delay, None)
return status, body, resp_headers
except urllib.error.HTTPError as e:
last_exception = e
if not self._should_retry(e, e.code):
self._log_attempt(
attempt + 1, False, 0,
f"HTTP {e.code} (nao retryable)"
)
raise
if attempt < self.config.max_retries:
delay = self.config.get_delay(attempt)
# Respeita Retry-After header se presente
retry_after = e.headers.get("Retry-After")
if retry_after:
try:
delay = max(delay, float(retry_after))
except ValueError:
pass
self._log_attempt(
attempt + 1, False, delay, f"HTTP {e.code}"
)
time.sleep(delay)
else:
self._log_attempt(
attempt + 1, False, 0,
f"HTTP {e.code} (esgotou retries)"
)
except Exception as e:
last_exception = e
if not self._should_retry(e, None):
self._log_attempt(
attempt + 1, False, 0,
f"{type(e).__name__} (nao retryable)"
)
raise
if attempt < self.config.max_retries:
delay = self.config.get_delay(attempt)
self._log_attempt(
attempt + 1, False, delay,
f"{type(e).__name__}: {e}"
)
time.sleep(delay)
else:
self._log_attempt(
attempt + 1, False, 0,
f"{type(e).__name__} (esgotou retries)"
)
raise RuntimeError(
f"Falhou apos {self.config.max_retries + 1} tentativas. "
f"Ultimo erro: {last_exception}"
)
def get_stats(self):
"""Retorna estatisticas das tentativas."""
total = len(self.attempts_log)
successes = sum(1 for a in self.attempts_log if a["success"])
return {
"total_attempts": total,
"successes": successes,
"failures": total - successes,
"total_delay": sum(a["delay_before"] for a in self.attempts_log),
"attempts": self.attempts_log,
}
Uso na Pratica
Agora vamos usar isso num cenario real: consumir uma API que pode estar instavel.
# Exemplo 1: GET simples com retry
client = RetryWithBackoff(RetryConfig(max_retries=3, base_delay=0.5))
try:
status, body, headers = client.fetch("https://api.github.com/zen")
print(f"Status: {status}")
print(f"Resposta: {body.decode()}")
except RuntimeError as e:
print(f"Erro final: {e}")
# Exemplo 2: Configuracao mais robusta
config = RetryConfig(
max_retries=5,
base_delay=1.0,
max_delay=30.0, # Nunca espera mais que 30s
jitter=True,
)
client = RetryWithBackoff(config)
try:
status, body, headers = client.fetch(
url="https://httpbin.org/status/503",
method="GET",
headers={"User-Agent": "MinhaAutomacao/1.0"},
timeout=5.0,
)
except RuntimeError as e:
print(f"Erro: {e}")
print(f"Estatisticas: {client.get_stats()}")
Decorator: Reutilizando em Qualquer Funcao
Se voce quer aplicar retry em funcoes que nao sao HTTP (banco de dados, filesystem, etc.), aqui esta um decorator generico:
import functools
import time
import random
from typing import Tuple, Type
def retry_with_backoff(
max_retries: int = 5,
base_delay: float = 1.0,
max_delay: float = 60.0,
jitter: bool = True,
retryable_exceptions: Tuple[Type[Exception], ...] = (Exception,),
):
"""
Decorator que adiciona retry com backoff exponencial a qualquer funcao.
Uso:
@retry_with_backoff(max_retries=3, base_delay=0.5)
def minha_funcao_fragil():
...
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except retryable_exceptions as e:
last_exception = e
if attempt >= max_retries:
break
delay = base_delay * (2 ** attempt)
delay = min(delay, max_delay)
if jitter:
delay = random.uniform(0, delay)
print(
f"[Retry] {func.__name__} tentativa "
f"{attempt + 1} falhou: {e} | "
f"aguardando {delay:.2f}s"
)
time.sleep(delay)
raise RuntimeError(
f"{func.__name__} falhou apos {max_retries + 1} "
f"tentativas. Ultimo erro: {last_exception}"
) from last_exception
return wrapper
return decorator
# Uso pratico
@retry_with_backoff(
max_retries=3,
base_delay=0.5,
retryable_exceptions=(ConnectionError, TimeoutError, OSError),
)
def salva_no_banco(dados):
"""Simula operacao de banco que pode falhar temporariamente."""
import random
if random.random() < 0.7: # 70% de chance de falhar
raise ConnectionError("Banco temporariamente indisponivel")
return "Dados salvos com sucesso!"
# Testando
try:
resultado = salva_no_banco({"user": "alisson", "action": "login"})
print(resultado)
except RuntimeError as e:
print(f"Falhou definitivamente: {e}")
O Perrengue do Olivetto
Eu implementei retry sem jitter numa automacao que monitorava 50 endpoints a cada minuto. Tudo funcionava lindo – ate que um dia os 50 endpoints cairam ao mesmo tempo (o provedor reiniciou o load balancer). Meus 50 workers retryaram sincronizados, todos no mesmo instante, e o provedor interpretou como ataque DDoS. Bloqueou meu IP por 24 horas.
A licao: retry sem jitter nao e so “menos eficiente” – pode te transformar no atacante. Depois disso, jitter virou obrigatorio em qualquer retry que eu escrevo. E adicionar e literalmente uma linha: delay = random.uniform(0, delay).
Quando NAO Usar Retry
Nem todo erro merece retry. Aqui esta o guia rapido:
- NAO retrye: 400 (Bad Request), 401 (Unauthorized), 403 (Forbidden), 404 (Not Found), 422 (Unprocessable Entity). Esses erros nao vao se resolver sozinhos.
- Retrye: 429 (Rate Limited), 500, 502, 503, 504. Esses sao temporarios por natureza.
- Retrye: TimeoutError, ConnectionError, URLError. Problemas de rede se resolvem com tempo.
- NAO retrye: ValueError, TypeError, KeyError. Sao bugs no seu codigo. Corrija, nao retrye.
Comparacao Visual: Com vs Sem Jitter
Pra quem gosta de ver os numeros, aqui esta uma simulacao de 10 clientes retryando simultaneamente:
import random
def simula_sem_jitter(num_clientes=10, max_retries=4, base_delay=1.0):
"""Mostra quando cada cliente retrya sem jitter."""
print("=== SEM JITTER (todos sincronizados) ===")
for attempt in range(max_retries):
delay = base_delay * (2 ** attempt)
print(f"Tentativa {attempt + 1}: todos retryam em {delay:.0f}s")
print()
def simula_com_jitter(num_clientes=10, max_retries=4, base_delay=1.0):
"""Mostra quando cada cliente retrya com full jitter."""
print("=== COM FULL JITTER (dessincronizado) ===")
for attempt in range(max_retries):
max_d = base_delay * (2 ** attempt)
tempos = [
f"{random.uniform(0, max_d):.1f}s"
for _ in range(num_clientes)
]
print(f"Tentativa {attempt + 1}: {', '.join(tempos)}")
simula_sem_jitter()
simula_com_jitter()
O resultado fala por si: sem jitter, todos batem no servidor nos mesmos instantes. Com jitter, a carga se distribui no tempo.
Checklist de Producao
Antes de colocar seu retry em producao, verifique:
- Todas as chamadas externas (HTTP, banco, fila) tem retry?
- O jitter esta habilitado?
- Existe um
max_delaypra evitar esperas absurdas? - Voce respeita o header
Retry-Afterquando o servidor envia? - Erros nao-retryable (4xx exceto 429) sao tratados diferente?
- Existe logging/observabilidade pra saber quantos retries estao acontecendo?
- O timeout individual de cada tentativa e razoavel?
Proximo Passo
Agora que voce tem retry com backoff exponencial e jitter funcionando, o proximo nivel e combinar isso com um Circuit Breaker (que ja cobrimos aqui no blog). O Circuit Breaker detecta quando um servico esta consistentemente falhando e para de tentar por um tempo, evitando desperdicio de recursos. Retry cuida de falhas temporarias; Circuit Breaker cuida de falhas prolongadas. Juntos, eles formam uma defesa solida pra qualquer automacao que depende de servicos externos.
E voce? Ja teve uma automacao que morreu silenciosamente porque nao tinha retry? Ou pior – que era o problema porque retryava errado demais? Conta aqui nos comentarios qual foi o perrengue. Os melhores bugs viram os melhores posts.
