Retry sem orçamento transforma falha pequena em pane
Seu backend aguenta 10 mil requisições por segundo. O tráfego sobe para 10.100. As 100 excedentes falham, então os clientes tentam de novo imediatamente.
No segundo seguinte chegam 10.200. Depois, 10.300. Em pouco tempo, o serviço gasta mais recurso recusando repetição do que concluindo trabalho útil.
A falha era pequena. O código de “resiliência” fabricou a pane.
Retry também é tráfego
Normalmente a repetição nasce como um catch: deu erro, espera um pouco e tenta outra vez. Só que a nova tentativa cai justamente no sistema que acabou de dizer que não conseguiu processar a anterior.
O livro de SRE do Google usa o cenário de 10 mil QPS para explicar esse feedback positivo. Um excesso inicial de 100 QPS produz 100 retries; eles elevam a carga, geram 200 falhas, depois 300. CPU, memória e descritores são consumidos até réplicas começarem a cair. O capítulo diz que esse padrão já participou de várias falhas em cascata reais, tanto com RPC quanto com JavaScript no navegador e sincronização offline.
10.100 QPS originais
100 falham
100 repetem ──┐
200 falham │ realimentação
200 repetem ──┘
Voltar o tráfego original para 9 mil QPS pode não bastar. Se só parte das réplicas continua saudável ou se recusar uma requisição custa caro, as tentativas antigas mantêm o serviço acima da capacidade reduzida.
Antes de escolher o tempo de espera, responda se o sistema pode pagar por outra tentativa.
Três retries podem virar 64 chamadas
Imagine navegador, frontend e backend. Cada camada faz três retries, ou seja, até quatro tentativas no total. O banco não recebe quatro chamadas para uma ação do usuário:
navegador × frontend × backend
4 × 4 × 4 = 64 tentativas
Esse exemplo de 64 chamadas vem da orientação do Google para evitar falhas em cascata. O conserto não está no tempo de backoff. Uma única camada precisa ser dona da repetição para aquela dependência, normalmente a imediatamente acima dela, que conhece o erro e duplica menos trabalho.
Um app pode repetir o pagamento inteiro enquanto o SDK dentro do servidor também repete a chamada. Mesmo um backoff perfeito só distribui as 64 tentativas no relógio.
Propague metadados como número da tentativa, ID da operação e decisão “não repetir”. Sem isso, o backend devolve uma informação específica, a camada do meio transforma em 500 e a próxima camada interpreta o erro genérico como autorização para começar tudo outra vez.
Só repita trabalho idempotente
Falha de transporte cria uma ambiguidade chata: o servidor pode ter confirmado a operação e perdido apenas a resposta. Repetir uma leitura costuma ser seguro. Repetir POST /pix pode registrar duas cobranças.
A RFC 9110 considera PUT, DELETE e os métodos seguros como idempotentes. Ela também orienta o cliente a não repetir automaticamente uma operação não idempotente sem saber que a semântica é repetível ou que a tentativa original não foi aplicada.
O verbo HTTP não salva uma implementação ruim. Um DELETE pode enviar e-mail duas vezes. Já um POST com chave de idempotência e persistência correta pode ser repetido. A garantia precisa existir na operação de negócio:
async function criarPedido(request) {
const chave = request.headers.get('Idempotency-Key');
if (!chave) return new Response('chave obrigatória', { status: 400 });
return db.transaction(async (tx) => {
const anterior = await tx.idempotency.find(chave);
if (anterior) {
return Response.json(anterior.body, { status: anterior.status });
}
const pedido = await tx.orders.insert(await request.json());
await tx.idempotency.insert({ chave, status: 201, body: pedido });
return Response.json(pedido, { status: 201 });
});
}
O registro da chave e o efeito precisam entrar na mesma transação. Fazer “consulta e depois insere” fora dela abre corrida: duas cópias não encontram a chave e criam dois pedidos. Use restrição única e defina escopo, prazo de retenção e como a resposta original será reproduzida.
Erro de autenticação, payload inválido e regra de negócio rejeitada não viram sucesso com espera. Repeti-los só rouba capacidade do tráfego válido.
Backoff sem jitter agenda o próximo pico
Backoff exponencial aumenta a espera a cada falha. Se mil clientes falham juntos e todos aguardam exatamente 100 ms, depois 200 ms, depois 400 ms, eles continuam sincronizados. O pico não sumiu; ganhou agenda.
A AWS simulou 100 clientes concorrendo para atualizar o mesmo objeto. Com backoff exponencial puro, as chamadas ainda chegavam em blocos. Com full jitter — um sorteio entre zero e o limite exponencial — o número de chamadas caiu mais da metade em relação ao cenário sem jitter, e o tempo para concluir também melhorou.
Uma política compacta em JavaScript fica assim:
const REPETIVEIS = new Set([408, 429, 500, 502, 503, 504]);
async function fetchComRetry(url, options = {}) {
const maxTentativas = 3;
const baseMs = 100;
const tetoMs = 2_000;
const deadlineMs = performance.now() + 5_000;
for (let tentativa = 0; tentativa < maxTentativas; tentativa++) {
const restanteMs = deadlineMs - performance.now();
if (restanteMs <= 0) throw new DOMException('prazo de retry esgotado', 'TimeoutError');
try {
const resposta = await fetch(url, {
...options,
signal: AbortSignal.timeout(Math.ceil(Math.min(1_500, restanteMs))),
});
if (!REPETIVEIS.has(resposta.status) || tentativa === maxTentativas - 1) {
return resposta;
}
const servidor = parseRetryAfter(resposta.headers.get('retry-after'));
const janela = Math.min(tetoMs, baseMs * 2 ** tentativa);
const atrasoMs = servidor ?? Math.random() * janela;
if (atrasoMs >= deadlineMs - performance.now()) return resposta;
await sleep(atrasoMs);
} catch (erro) {
if (tentativa === maxTentativas - 1) throw erro;
const restanteAposFalha = deadlineMs - performance.now();
if (restanteAposFalha <= 0) throw erro;
const janela = Math.min(tetoMs, baseMs * 2 ** tentativa);
await sleep(Math.min(Math.random() * janela, restanteAposFalha));
}
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function parseRetryAfter(value) {
if (!value) return null;
const segundos = Number(value);
if (Number.isFinite(segundos)) return Math.max(0, segundos * 1_000);
const dataMs = Date.parse(value);
return Number.isFinite(dataMs) ? Math.max(0, dataMs - Date.now()) : null;
}
A lista é ponto de partida, não lei universal. Sua API precisa dizer quais erros são transitórios. Um 500 causado por bug determinístico falhará de novo. Uma conexão interrompida depois de enviar um POST deixa resultado ambíguo. Um código de domínio no corpo pode impedir a repetição mesmo com status normalmente elegível.
Também ouça o servidor. A RFC 9110 permite Retry-After em respostas 503 como segundos ou data HTTP. Trate o valor como espera mínima, rejeite números absurdos e respeite o prazo total do chamador. Esperar 120 segundos não ajuda quando a requisição inteira tem só 800 ms restantes.
Orçamento de retry corta a realimentação
O limite de tentativas contém uma operação. O orçamento contém todas as operações durante uma pane.
O Google descreve dois controles combinados: no máximo três tentativas por requisição e taxa de retries abaixo de 10% por cliente. Só o primeiro limite deixa o pior caso perto de 3× o tráfego. A taxa de 10% segura o crescimento geral em aproximadamente 1,1×.
Essa é a diferença entre “cada falha ganha duas chances” e “repetições podem consumir no máximo 10% do tráfego atual”. Quando o saldo acaba, falhe rápido. O usuário vê erro, mas a dependência recebe silêncio suficiente para se recuperar.
Os SDKs da AWS aplicam o mesmo princípio com token bucket. O modo padrão combina full jitter com uma cota: falhas espalhadas drenam tokens; quando o balde esvazia, a chamada retorna erro sem repetir. Sucessos de primeira devolvem crédito ao orçamento.
Separe o saldo por dependência e, quando houver vizinho barulhento, por cliente ou classe de operação. O serviço saudável de estoque não deve doar todas as tentativas para o recomendador quebrado. Job assíncrono também não deveria disputar a mesma política com a tela de checkout.
Meça a amplificação
Um gráfico de 503 subindo não mostra se o retry está salvando chamadas ou alimentando o incidente. Registre, lado a lado:
- taxa de requisições originais e taxa de retries;
- tentativas por operação lógica;
- sucesso depois de retry e falha final;
- atraso sorteado e uso de
Retry-After; - orçamento esgotado e decisões “não repetir”;
- saturação da dependência: fila, percentis de latência e rejeições.
Crie a métrica (originais + retries) / originais. Uma amplificação de 1,02 durante ruído isolado pode estar saudável. A razão caminhando para 1,5 enquanto o throughput útil cai é feedback positivo.
No incidente, a mitigação mais eficiente pode ser desligar retries, rejeitar trabalho de baixa prioridade ou devolver explicitamente “sobrecarregado; não tente de novo”. Subir réplicas nem sempre resolve: cache frio e clientes sincronizados podem afogar cada processo novo assim que ele entra no balanceador.
O checklist que eu usaria no review
Antes de aprovar um loop de retry, eu cobraria sete respostas:
- Quais erros exatos são transitórios?
- A operação é idempotente ou usa chave atômica?
- Qual única camada é dona da repetição?
- Qual é o limite de tentativas e o prazo total?
- O atraso usa full jitter e respeita
Retry-After? - Qual orçamento global interrompe retries numa falha ampla?
- Qual métrica revela a amplificação?
Retry gasta capacidade para comprar outra probabilidade de sucesso. Só vale pagar quando a falha é transitória, repetir é seguro e existe orçamento para a nova tentativa.
Qual retry do seu sistema hoje não tem dono, orçamento ou garantia de idempotência?
Fonte: https://luciano655.com