Home Comunidade Eventos YouTube Networks Desafios // produtos Academy News CTI | CVE Contato

01 - Retry com Backoff Exponencial

#backoff-exponencial#erros-transientes#jitter#resiliencia#retry
video explicativo
Video explicativoEspaco reservado para o video desta anotação — adicione video: https://youtu.be/... no frontmatter da nota.

Retry com Backoff Exponencial

Retry com backoff exponencial é o padrão de reexecutar automaticamente uma operação que falhou, aumentando o intervalo entre tentativas a cada novo fracasso. A premissa é que boa parte dos erros em sistemas distribuídos é transiente: pico de latência, failover de instância, pausa de garbage collector ou conexão encerrada por keepalive. Repetir de imediato, com cadência fixa, transforma falha pontual em rajada de tráfego; espaçar as tentativas de forma crescente dá tempo para o destino se recuperar e alivia a pressão sobre um sistema já enfraquecido. O padrão é relevante em produção porque toda integração síncrona e todo consumo de fila convivem com instabilidade esporádica, e a diferença entre um sistema ruidoso e um resiliente está em como esses episódios são tratados. A contrapartida exige disciplina: retry mal configurado é causa recorrente de incidentes autoinfligidos, porque multiplica carga exatamente quando o sistema menos suporta.

Por que erros transientes existem

Erro transiente é aquele que tende a desaparecer sem intervenção de código, apenas com o passar do tempo. As causas formam um repertório conhecido: saturação momentânea do pool de conexões do banco, failover de réplica durante deploy, limitação de taxa com HTTP 429 imposta pelo provedor, perda breve de pacote na rede, coleta de lixo que congela o processo por centenas de milissegundos e reinicialização de pod em eventos de escalonamento automático.

Esses eventos não são defeitos do código; são propriedades do ambiente distribuído. Um serviço que faz cem chamadas por segundo será atingido por vários deles ao longo do dia, mesmo com todos os componentes saudáveis. Tratar cada ocorrência como erro fatal produz alarmes falsos, jobs interrompidos no meio e transações abandonadas que exigem reconciliação manual depois.

A fronteira do padrão está na classificação do erro. Transientes merecem nova tentativa; erros permanentes, como payload inválido com HTTP 400, credencial rejeitada com HTTP 401 ou violação de regra de negócio com HTTP 422, nunca se resolvem por repetição e precisam seguir para tratamento próprio. Retry aplicado a erro permanente só adiciona latência, custo e ruído de log.

O problema da tentativa imediata

A primeira intuição é repetir a chamada logo após a falha, o que raramente funciona e frequentemente agrava o cenário. Se a causa foi sobrecarga, o destino segue sobrecarregado no instante seguinte, e a nova tentativa encontra as mesmas condições. Pior: quando muitos clientes sofrem a mesma falha simultânea e todos repetem no mesmo ritmo, as tentativas chegam em ondas sincronizadas, comportamento conhecido como thundering herd.

Com cadência fixa, por exemplo uma tentativa a cada segundo, os clientes permanecem alinhados entre si, porque falharam juntos e repetem juntos. O backoff exponencial quebra parte desse alinhamento ao crescer o intervalo, e o jitter o destrói por completo ao sortear o atraso de cada cliente de forma independente. A combinação de crescimento e aleatoriedade é o que separa um retry inofensivo de uma tempestade de tentativas, a retry storm, em que o tráfego de recuperação supera o tráfego original da aplicação.

A amplificação é multiplicativa e fácil de subestimar. Cada camada da pilha que retenta multiplica o fator da camada anterior: um cliente com três tentativas dentro de um gateway que também retenta três vezes impõe até nove requisições ao destino por chamada original. Sob queda real de disponibilidade, esse multiplicador decide entre recuperação em segundos e colapso em cascata.

Funcionamento interno: backoff e jitter

O cálculo clássico usa um atraso base multiplicado por potências de dois: 100 ms, 200 ms, 400 ms, 800 ms e assim por diante, sempre com teto para impedir esperas de minutos. Sobre esse valor aplica-se o jitter, um sorteio aleatório que distribui as tentativas no tempo. Sem sorteio, clientes que falharam juntos ressincronizam nos mesmos instantes; com sorteio, cada um tenta em momento próprio e a soma das tentativas se aproxima de uma carga uniforme em vez de picos concentrados.

import random
import time

class ErroTransiente(Exception):
    """Erro retentável: falha de rede, timeout ou HTTP 503 e 429."""

# Política de retry com backoff exponencial e full jitter
MAX_TENTATIVAS = 5     # total de tentativas, incluindo a primeira execução
BASE_MS = 100          # atraso base após a primeira falha
TETO_MS = 10_000       # teto do atraso; impede esperas crescentes sem limite

def atraso_ms(tentativa: int) -> int:
    # crescimento exponencial: 100, 200, 400, 800, 1600 ms
    backoff = min(TETO_MS, BASE_MS * (2 ** tentativa))
    # full jitter: sorteio uniforme entre 0 e o valor calculado;
    # distribui as tentativas no tempo e quebra ondas sincronizadas
    return random.randint(0, backoff)

def executar_com_retry(operacao, *args):
    for tentativa in range(MAX_TENTATIVAS):
        try:
            return operacao(*args)
        except ErroTransiente:
            if tentativa == MAX_TENTATIVAS - 1:
                raise  # orçamento de tentativas esgotado; propagar ao chamador
        # espera o atraso sorteado antes da próxima execução
        time.sleep(atraso_ms(tentativa) / 1000)

A escolha do teto é tão importante quanto o crescimento, porque define o tempo máximo que uma operação pode ficar esperando antes de desistir, e esse tempo precisa caber no orçamento de latência do chamador. Em jobs em segundo plano o teto pode ser generoso; em chamadas que respondem a usuário, precisa caber em centenas de milissegundos. Há ainda a interação com a latência da própria tentativa: cada execução consome o tempo de resposta do destino, então a espera total é a soma dos atrasos e das durações das tentativas, não apenas a soma dos atrasos.

Variantes de jitter

Variante Cálculo do atraso Perfil de dispersão Indicação
Sem jitter Valor exponencial integral Tentativas ressincronizadas Nenhum cenário com múltiplos clientes
Full jitter Sorteio uniforme entre zero e o exponencial Dispersão máxima Default recomendado para chamadas de rede
Equal jitter Metade fixa do valor mais sorteio da outra metade Piso determinístico com dispersão Destinos com janela de recuperação previsível
Decorrelacionado Sorteio entre o atraso anterior e o triplo dele Adaptação individual por cliente Clientes de longa vida contra destinos instáveis

Full jitter maximiza a dispersão e costuma vencer em cenários de alta concorrência, porque nenhuma componente determinística concentra tentativas no mesmo instante. Equal jitter é útil quando o destino publica janelas de recuperação conhecidas e tentar cedo demais é desperdício garantido. A variante decorrelacionada evita o efeito de esperas curtas repetidas quando o atraso anterior já foi alto, o que a torna comum em clientes persistentes que acompanham a saúde do destino por horas.

Limites de tentativa e orçamento de retry

Sem limite de tentativas, o padrão vira loop infinito disfarçado de resiliência. O limite típico fica entre três e cinco tentativas totais, valor que cobre a esmagadora maioria dos episódios transientes e mantém a espera acumulada previsível: com base de 100 ms e teto de dez segundos, cinco tentativas somam poucos segundos de espera na mediana. Cada dependência merece valores próprios, derivados de medição e não de conveniência.

Além do limite por chamada, sistemas maduros adotam orçamento de retry no nível do cliente: uma fração máxima do tráfego total, tipicamente dez a vinte por cento, pode ser consumida por tentativas adicionais. Quando o orçamento se esgota, novas falhas deixam de gerar retry e seguem direto para tratamento de erro. O mecanismo trava o fator de amplificação, pois a carga imposta ao destino nunca passa do tráfego original multiplicado pelo teto do orçamento, ainda que a taxa de erro suba.

Quando o destino expõe o cabeçalho Retry-After, ele deve prevalecer sobre o cálculo local: quem sofre a sobrecarga sabe melhor quando aceitar tráfego de volta. Ignorar esse cabeçalho é uma das formas mais eficientes de prolongar a instabilidade alheia e de ver o próprio tráfego descartado no limite do destino.

Aplicação prática: política declarativa

Política de retry espalhada em constantes pelo código é impossível de governar; a prática consolidada é declará-la por dependência em configuração, com classificação explícita de exceções retentáveis e proibidas.

# Política declarativa no estilo Resilience4j aplicada a um cliente de pagamentos
resilience4j:
  retry:
    instances:
      pagamentos:
        max-attempts: 4                       # falha inicial mais três tentativas
        wait-duration: 100ms                  # atraso base do backoff
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2.0
        exponential-max-wait-duration: 10s    # teto do atraso
        retry-exceptions:                     # apenas erros transientes alimentam o loop
          - java.io.IOException
          - java.util.concurrent.TimeoutException
        ignore-exceptions:                    # erros permanentes falham na primeira vez
          - com.acme.erro.RegraNegocioException

A configuração acima deixa explícito o contrato que o loop espera: exceções de infraestrutura são retentáveis, exceções de negócio não. Essa separação por tipo, e não por mensagem de erro, evita a classe inteira de incidentes em que o texto de log mudou e a classificação quebrou em silêncio.

Comparação entre estratégias de espera

Estratégia Cálculo do atraso Comportamento sob falha em massa Quando usar
Sem retry Não se aplica Falha transiente derruba a operação Operação não idempotente sem chave de idempotência
Imediato Zero Ondas sincronizadas e amplificação imediata Praticamente nenhum caso em produção
Intervalo fixo Constante entre tentativas Clientes permanecem alinhados entre si Jobs com janela ampla e volume baixo
Exponencial sem jitter Base vezes dois elevado à tentativa Rajadas com frequência decrescente Ponto de partida, nunca em escala alta
Exponencial com full jitter Sorteio entre zero e o exponencial Dispersão máxima das tentativas Default para chamadas de rede
Exponencial com Retry-After Atraso ditado pelo destino Obediência ao ritmo do servidor Chamadas a APIs que publicam o cabeçalho

A leitura prática é simples: intervalo fixo e exponencial puro apenas deslocam o problema no tempo; a aleatoriedade é o ingrediente que reduz a correlação entre clientes. Em combinação com Retry-After, o sorteio local perde relevância, porque o servidor passa a ditar o ritmo de todas as tentativas.

Combinação com idempotência

Retry só é seguro quando repetir a operação não produz efeito duplicado, propriedade chamada idempotência. Consultas e exclusões por identificador costumam ser idempotentes por natureza; criações de recurso e cobranças financeiras não são, e uma tentativa após timeout ambíguo pode cobrar o cliente duas vezes. O timeout é ambíguo justamente porque o cliente não sabe se a requisição chegou ao destino, se foi processada ou se falhou antes do commit.

A solução corrente é a chave de idempotência: o cliente gera um identificador único por operação de negócio e o reenvia em cada tentativa; o destino reconhece a repetição, devolve o resultado original e executa o efeito uma única vez. Gateways de pagamento e APIs de crédito adotaram esse contrato como prática de mercado, e filas com entrega at-least-once exigem o mesmo esforço do consumidor, porque mensagem reentregue é o equivalente assíncrono do retry.

Sem idempotência garantida, o teto seguro do padrão é uma única tentativa adicional, e mesmo ela carrega risco. O custo do retry descuidado não aparece na execução; aparece na consistência dos dados, em duplicidade descoberta por conciliação semanas depois.

Retry síncrono versus assíncrono

Em chamadas síncronas, o retry acontece dentro do orçamento de latência do chamador, o que restringe o número de tentativas e o tamanho dos atrasos. Responder o usuário depois de dez segundos costuma ser pior do que responder falha rápida com fallback, então o teto de tentativas deve nascer do SLO do endpoint e não do desejo de sucesso a qualquer preço.

Em consumo assíncrono, a reexecução é natural: a mensagem volta à fila com atraso, o intervalo cresce a cada reentrega e, ao esgotar as tentativas, o item segue para a DLQ com alerta. O orçamento de latência desaparece e dá lugar ao orçamento de atraso total, que limita quanto tempo um evento pode ficar em recuperação antes de exigir intervenção humana. As duas formas compartilham o mesmo cálculo de backoff; mudam apenas os limites.

Erros comuns

  • Retentar erros permanentes, como HTTP 400, 401, 403 e 422, porque repetição não corrige payload, autenticação nem autorização; a correção exige classificação explícita antes do loop.
  • Ausência de teto de tentativas ou de teto de atraso, que converte falha breve em espera de minutos ou em loop contínuo consumindo threads e conexões.
  • Backoff sem jitter em volume alto de clientes, que ressincroniza as ondas de tentativa e reproduz o thundering herd que o padrão deveria evitar.
  • Retry em múltiplas camadas da pilha, cliente HTTP, SDK, gateway e service mesh, porque os fatores se multiplicam e quatro camadas com três tentativas viram dezenas de requisições.
  • Retry de operações não idempotentes sem chave de idempotência, que troca indisponibilidade temporária por duplicidade permanente de cobrança ou de recurso.
  • Ignorar o cabeçalho Retry-After e os sinais de backpressure do destino, mantendo pressão máxima sobre o sistema que pediu alívio.
  • Retry dentro de chamadas síncronas de interface com orçamento de latência apertado, em que a espera acumulada piora a experiência em vez de resgatar a operação.
  • Falta de métricas por tentativa, que deixa a política invisível para observabilidade e impede perceber que o sucesso vem apenas da terceira execução, sinal claro de dependência doente.

Boas práticas

  • Classificar erros por código e por exceção antes de habilitar o loop, com listas explícitas de condições retentáveis e não retentáveis versionadas junto do código.
  • Configurar sempre três parâmetros, teto de tentativas, atraso base e teto de atraso, revisados por dependência em vez de herdados globalmente.
  • Adotar full jitter como default e centralizar a política em configuração, não em constantes espalhadas pelos módulos.
  • Garantir idempotência antes de retentar escritas, com chaves de idempotência previstas no contrato das operações de negócio.
  • Limitar retry a uma única camada da pilha por chamada e usar orçamento de retry para travar a amplificação no nível do cliente.
  • Combinar com circuit breaker: enquanto a dependência está aberta, o loop nem deve iniciar, e o fallback responde na hora.
  • Instrumentar a taxa de sucesso por número da tentativa e o atraso médio adicionado, porque política de retry invisível é política sem governança.
  • Em consumo de fila, alinhar retry, reentrega com atraso e DLQ numa única política, para que o mesmo cálculo de backoff governe os dois caminhos.