Home Projetos & Releases BigBangcraft Blog Sobre & Carreira
Arquitetura & CTO 6 min de leitura 36 visualizações
Raw .md

O perigo de configurar retentativas automáticas sem orçamento de tempo e chave de idempotência

PD

Pedro Dalben

CTO no Integrar Plus • Publicado em 09 de October de 2026

O perigo de configurar retentativas automáticas sem orçamento de tempo e chave de idempotência

Adicionar uma diretiva de três retentativas com backoff exponencial leva cinco minutos na maioria dos clientes HTTP. Parece a decisão mais prudente da semana: protege contra oscilações de rede, reduz erros esporádicos no monitoramento e compra tempo para o serviço externo se recuperar. Na prática, esse reflexo transforma lentidões pontuais em colapsos coordenados de infraestrutura.

O debate recente em torno de como agentes de inteligência artificial lidam com cenários de falha expôs um racha técnico que atinge desenvolvedores humanos há anos. Ao avaliar se uma requisição com erro 503 contendo um cabeçalho Retry-After deveria ser reenviada, parte dos engenheiros defendeu que seguir o cabeçalho após o intervalo indicado é seguro porque um 503 costuma vir do balanceador de carga antes de a requisição tocar o código de aplicação. Outro grupo alertou que reenviar qualquer mutação, como uma cobrança, sem chave de idempotência explícita é negligência operacional grave, já que o código pode muito bem ter executado e travado na hora de devolver a resposta.

Eu fico categoricamente do lado do segundo grupo, mas acho que ambos os lados esquecem o fator que realmente derruba sistemas reais: o orçamento total da requisição.

A discussão sobre reenviar ou esperar parte de uma premissa isolada, como se cada tentativa existisse no vácuo de uma única chamada de função. Em um sistema com fila de processamento assíncrono ou uma requisição web que orquestra dois serviços internos e um gateway externo, a pergunta correta não é se a chamada individual aceita nova tentativa. A pergunta é quanto tempo de vida resta para a operação inteira.

Se um fluxo de checkout possui um tempo limite global de três segundos no balanceador da sua VPS e o primeiro envio consome dois segundos e meio antes de receber um 503, gastar outros quinhentos milissegundos tentando de novo é inútil. O cliente na outra ponta já recebeu erro de tempo esgotado ou cancelou a conexão. Reenviar o payload consome banda, tempo de CPU e conexões do pool para processar algo cujo resultado ninguém vai consumir.

Tratar tentativas como um contador inteiro (attempts: 3) é o primeiro erro de projeto. Cada chamada precisa carregar um prazo absoluto herdado do contexto pai. Se o prazo restante não cobrir o tempo estimado de ida e volta somado ao tempo mínimo de processamento, a biblioteca precisa abortar imediatamente em vez de insistir.

O segundo colapso ocorre quando dezenas de instâncias ou workers de background tomam a mesma decisão ao mesmo tempo. Suponha que o banco de dados principal ou um serviço de integração fique indisponível por dez segundos. Se cem processos falharem no mesmo segundo e todos aplicarem um backoff exponencial matemático puro de dois, quatro e oito segundos, eles não estão aliviando o tráfego. Estão marchando em sincronia.

O serviço de destino tenta voltar ao ar, recebe cem requisições simultâneas no segundo segundo, cai de novo, tenta se reerguer e é golpeado outra vez no quarto segundo. Esse é o clássico efeito manada.

A única forma matemática de quebrar essa sincronização forçada é adicionar jitter, que consiste em espalhar um fator aleatório no cálculo da espera:

def backoff_com_jitter(tentativa, base: 0.5, teto: 10.0)
  fator = [teto, base * (2 ** tentativa)].min
  rand(0.0..fator)
end

Distribuir a carga uniformemente entre zero e o teto exponencial transforma uma tempestade de requisições alinhadas em uma névoa dispersa de tentativas, permitindo que a infraestrutura subjacente esvazie suas filas de conexão sem sofrer picos artificiais.

Ainda assim, dispersar chamadas e calcular prazos não resolve o maior risco operacional: duplicidade de efeitos colaterais.

O protocolo HTTP não garante que um erro 500, 502, 503 ou 504 signifique ausência de processamento. Em conexões que passam por proxies reversos, o balanceador pode encerrar a conexão com o cliente após atingir seu próprio tempo limite, enquanto a thread no servidor de aplicação continua processando a transação no PostgreSQL e gravando o registro até o fim. Se a sua aplicação decide tentar novamente só porque o método HTTP era idempotente na teoria ou porque o cabeçalho sugeriu um intervalo de espera, você acabou de cobrar o cartão duas vezes ou duplicou uma baixa de estoque.

Idempotência não é uma propriedade que surge no cliente por insistência; ela precisa ser imposta pelo servidor receptor por meio de chaves exclusivas vinculadas à operação de negócio:

class PagamentosController < ApplicationController
  def create
    chave = request.headers["Idempotency-Key"]
    return render(json: { erro: "Chave ausente" }, status: :bad_request) if chave.blank?

    registro = IdempotencyRecord.find_or_initialize_by(chave: chave)

    if registro.persisted? && registro.concluido?
      return render(json: registro.resposta_serializada, status: registro.status_http)
    end

    ActiveRecord::Base.transaction do
      registro.update!(status: "em_processamento")
      resultado = ProcessadorCobranca.executar(params[:valor])
      registro.update!(
        status: "concluido",
        status_http: 201,
        resposta_serializada: resultado.to_json
      )
    end

    render json: registro.resposta_serializada, status: :created
  rescue ActiveRecord::RecordNotUnique
    render json: { erro: "Requisicao concorrente em andamento" }, status: :conflict
  end
end

Quando a infraestrutura receptora armazena a chave com uma restrição de unicidade no banco de dados relacional, uma segunda tentativa disparada por qualquer cliente simplesmente recebe o resultado do processamento original sem provocar novos débitos. Se o serviço de destino não suporta esse tipo de contrato, o cliente não tem o direito de reenviar mutações automaticamente. Nesses casos, a falha precisa virar uma inconsistência explícita registrada em log para reconciliação manual ou processamento em lote de auditoria.

Por fim, quando o serviço de destino entra em falha prolongada, retentativas individuais com backoff perdem o sentido. Se todas as requisições estão falhando há dois minutos, insistir em esperar intervalos exponenciais apenas satura os workers da sua própria máquina, mantendo conexões abertas e exaurindo threads que poderiam estar atendendo outros fluxos saudáveis.

Nesse ponto entra o circuit breaker. Ele não calcula intervalos para uma única chamada, mas monitora a taxa global de erros daquele destino. Ao ultrapassar um limiar configurado, o circuito abre e todas as chamadas subsequentes falham na hora, sem encostar na rede e sem gastar CPU tentando reconectar. Passado um tempo de observação, o circuito permite que uma requisição de teste passe; se funcionar, ele fecha e restabelece o fluxo normal.

Resiliência em sistemas distribuídos não é sinônimo de persistência cega. Um cliente que insiste em bater em uma porta fechada sem saber quanto tempo lhe resta, sem aleatoriedade na espera e sem garantias de idempotência não é resiliente. Ele é apenas um ataque de negação de serviço caseiro esperando o primeiro soluço de rede para derrubar a própria operação.

Fontes

PD

Sobre Pedro Dalben

CTO e arquiteto full stack especializado em Ruby on Rails, Vue.js, sistemas multi-tenant e infraestrutura distribuída.