Retry com backoff, jitter e circuit breaker em TypeScript
Repetir na hora piora a queda que você está tentando contornar. As quatro regras que separam um retry útil de um ataque contra o seu próprio fornecedor.
O erro tratado aqui
HTTP 503 Service Unavailable: upstream connect error or disconnect/reset before headersAmbiente testado
- Node 22
- TypeScript 5.8
- Next.js 15.3
Contexto: o que estava rodando
Uma integração com a API de cotação de frete de um parceiro. Ela cai por um ou dois minutos algumas vezes por semana — nada catastrófico, e o esperado era que a nossa aplicação atravessasse essas janelas sem o usuário perceber.
O código tinha retry. Três tentativas, uma atrás da outra.
O erro
14:02:11 POST /api/frete → 503 Service Unavailable
14:02:11 retry 1/3 → 503 Service Unavailable
14:02:11 retry 2/3 → 503 Service Unavailable
14:02:12 retry 3/3 → 503 Service Unavailable
14:02:12 Error: upstream connect error or disconnect/reset before headersQuatro chamadas em novecentos milissegundos. Multiplicado pelas requisições que chegavam ao mesmo tempo, a nossa aplicação estava mandando quatro vezes mais tráfego para um serviço que já estava com problema.
A janela de indisponibilidade do parceiro, que costumava durar um minuto, passou a durar cinco.
Diagnóstico
Retry imediato é um ataque educado
Serviço sobrecarregado precisa de folga para se recuperar. Um retry sem espera faz o contrário: no exato momento em que ele está pior, cada cliente triplica a própria carga.
Pior ainda: como todos os clientes falham ao mesmo tempo, todos repetem ao mesmo tempo. As tentativas chegam em ondas sincronizadas em vez de distribuídas. O serviço levanta, leva uma onda na cara, cai de novo.

Nem todo erro merece repetição
O código repetia tudo. Isso significa que um 400 Bad Request — um corpo
malformado que nunca vai funcionar — era enviado quatro vezes.
| Situação | Repetir? | Motivo |
|---|---|---|
Falha de rede, ECONNRESET, timeout |
Sim | Transitória por natureza |
429 Too Many Requests |
Sim, respeitando Retry-After |
O serviço está dizendo quando voltar |
500, 502, 503, 504 |
Sim | Problema do lado de lá, pode passar |
408 Request Timeout |
Sim | Transitória |
400, 401, 403, 404, 422 |
Não | A requisição está errada; repetir não conserta |
409 Conflict |
Depende | Só se a causa for concorrência |
Repetir escrita não idempotente cria duplicata
O POST /cotacao era leitura disfarçada de escrita, então repetir era inofensivo.
Se fosse POST /pedido, cada repetição poderia gerar um pedido novo — inclusive
quando a chamada original funcionou e só a resposta se perdeu no caminho.
Contar tentativas não é suficiente em serverless
“Três tentativas” não diz nada sobre tempo. Com backoff crescente, três tentativas podem consumir mais tempo do que a função tem de vida — e aí a função morre no meio do retry, sem devolver nada nem registrar o motivo.
O limite que importa é o prazo, não o número.
A solução
-
Classificar o erro antes de decidir.
src/lib/resiliencia/classificar.tsexport type Veredito = { repetir: boolean; esperarMs?: number }; const STATUS_REPETIVEL = new Set([408, 425, 429, 500, 502, 503, 504]); export function classificarResposta(res: Response): Veredito { if (res.ok) return { repetir: false }; if (!STATUS_REPETIVEL.has(res.status)) return { repetir: false }; // Quando o serviço diz quando voltar, obedecer é melhor que adivinhar. const retryAfter = res.headers.get('retry-after'); if (retryAfter) { const segundos = Number(retryAfter); const esperarMs = Number.isFinite(segundos) ? segundos * 1000 : Math.max(0, new Date(retryAfter).getTime() - Date.now()); return { repetir: true, esperarMs }; } return { repetir: true }; } export function classificarErro(err: unknown): Veredito { // AbortError vindo do nosso próprio prazo não deve virar nova tentativa. if (err instanceof DOMException && err.name === 'AbortError') { return { repetir: false }; } // Falha de rede: o fetch rejeita com TypeError. return { repetir: err instanceof TypeError }; } -
Espalhar as tentativas com jitter total.
Backoff exponencial resolve a intensidade. Jitter resolve a sincronia. Sem jitter, mil clientes que falharam juntos voltam juntos em 1 s, 2 s, 4 s.
src/lib/resiliencia/espera.ts/** * Full jitter: sorteia entre zero e o teto exponencial. * Distribui as tentativas em vez de concentrá-las num instante. */ export function calcularEspera(tentativa: number, baseMs = 300, tetoMs = 20_000) { const teto = Math.min(tetoMs, baseMs * 2 ** tentativa); return Math.random() * teto; } export const dormir = (ms: number, signal?: AbortSignal) => new Promise<void>((resolve, reject) => { const id = setTimeout(resolve, ms); signal?.addEventListener('abort', () => { clearTimeout(id); reject(new DOMException('prazo esgotado', 'AbortError')); }, { once: true }); }); -
Trabalhar com prazo, não com contagem.
src/lib/resiliencia/fetch-resiliente.tsimport { classificarResposta, classificarErro } from './classificar'; import { calcularEspera, dormir } from './espera'; interface Opcoes { tentativasMax?: number; prazoTotalMs?: number; timeoutPorTentativaMs?: number; } export async function fetchResiliente( url: string, init: RequestInit = {}, { tentativasMax = 4, prazoTotalMs = 12_000, timeoutPorTentativaMs = 4_000 }: Opcoes = {}, ): Promise<Response> { const limite = AbortSignal.timeout(prazoTotalMs); let ultimoErro: unknown; for (let tentativa = 0; tentativa < tentativasMax; tentativa++) { try { const res = await fetch(url, { ...init, // Cada tentativa tem o próprio timeout, e o prazo total corta tudo. signal: AbortSignal.any([limite, AbortSignal.timeout(timeoutPorTentativaMs)]), }); const veredito = classificarResposta(res); if (!veredito.repetir) return res; ultimoErro = new Error(`HTTP ${res.status}`); // Corpo não lido mantém a conexão presa até o coletor de lixo passar. await res.body?.cancel(); const espera = veredito.esperarMs ?? calcularEspera(tentativa); await dormir(espera, limite); } catch (err) { if (limite.aborted) throw new Error('prazo total esgotado', { cause: ultimoErro ?? err }); if (!classificarErro(err).repetir) throw err; ultimoErro = err; await dormir(calcularEspera(tentativa), limite); } } throw new Error(`falhou após ${tentativasMax} tentativas`, { cause: ultimoErro }); }AbortSignal.anycombina os dois cortes: o da tentativa individual e o do prazo global. O primeiro que disparar vence. -
Parar de bater quando o serviço claramente caiu.
Retry resolve falha pontual. Se o serviço está fora há três minutos, cada requisição do usuário gasta o prazo inteiro esperando o inevitável — a sua aplicação fica lenta por causa da queda de outro.
O circuit breaker corta esse caminho: depois de N falhas seguidas, as chamadas falham na hora, sem tocar na rede. Passado o resfriamento, uma chamada de sondagem testa se voltou.
src/lib/resiliencia/circuito.tstype Estado = 'fechado' | 'aberto' | 'meio-aberto'; export class Circuito { private estado: Estado = 'fechado'; private falhas = 0; private abertoAte = 0; constructor( private readonly limiteFalhas = 5, private readonly resfriamentoMs = 30_000, ) {} async executar<T>(fn: () => Promise<T>): Promise<T> { if (this.estado === 'aberto') { if (Date.now() < this.abertoAte) { // Falha imediata: não gasta o prazo do usuário numa causa perdida. throw new Error('circuito aberto: serviço indisponível'); } this.estado = 'meio-aberto'; } try { const resultado = await fn(); this.falhas = 0; this.estado = 'fechado'; return resultado; } catch (err) { this.falhas++; // Em meio-aberto, uma única falha basta para reabrir: a sondagem disse // que ainda não voltou. if (this.estado === 'meio-aberto' || this.falhas >= this.limiteFalhas) { this.estado = 'aberto'; this.abertoAte = Date.now() + this.resfriamentoMs; } throw err; } } } -
Combinar os dois e ter um plano para quando falhar.
src/lib/frete.tsimport { Circuito } from './resiliencia/circuito'; import { fetchResiliente } from './resiliencia/fetch-resiliente'; const circuito = new Circuito(); export async function cotarFrete(cep: string, pesoGramas: number) { try { const res = await circuito.executar(() => fetchResiliente('https://api.parceiro.com/v1/cotacao', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cep, pesoGramas }), }), ); return await res.json(); } catch (err) { // Degradar é melhor que quebrar: a tabela fixa permite fechar a compra. console.error('cotação indisponível, usando tabela fixa', err); return tabelaFixa(cep, pesoGramas); } }
Como confirmar que resolveu
Simule a queda. Um servidor local que só devolve 503 mostra o comportamento sem incomodar o parceiro:
npx http-echo-server --port 9099 --status 503 &
Apontando a integração para ele, o log esperado tem esperas crescentes e irregulares:
14:31:02.104 tentativa 1 → 503
14:31:02.396 tentativa 2 → 503 (esperou 292 ms)
14:31:03.111 tentativa 3 → 503 (esperou 715 ms)
14:31:05.038 tentativa 4 → 503 (esperou 1927 ms)
14:31:05.038 falhou após 4 tentativas → tabela fixaRodando duas vezes, as esperas têm que ser diferentes. Iguais significa que o jitter não está sendo aplicado.
Verifique o corte por prazo. Com prazoTotalMs: 12000, nenhuma chamada pode
demorar mais que isso, independentemente do número de tentativas.
Confirme que o circuito abre. Depois de cinco falhas seguidas, a sexta chamada precisa falhar em poucos milissegundos, sem requisição de rede — visível na aba Network ou no log do servidor de teste, que para de receber.
Armadilhas que sobram depois disso
Em serverless, o circuito é por instância. Cada função tem a própria memória, então dez instâncias mantêm dez circuitos independentes e cada uma precisa falhar cinco vezes por conta própria. Para um corte de verdade, o estado precisa ser compartilhado — Redis ou uma linha no Postgres com contador e timestamp.
Retry aninhado multiplica. Se o SDK do fornecedor já repete internamente e você envolve em outro retry, quatro tentativas viram dezesseis. Verifique a configuração do SDK antes de acrescentar a sua camada.
Não repita escrita sem chave de idempotência. Uma resposta perdida no caminho é indistinguível de uma requisição que não chegou. Mande um cabeçalho de idempotência e deixe o outro lado deduplicar.
Registre a tentativa vencedora. Sem log de quantas tentativas foram necessárias, o retry esconde a degradação: tudo parece bem enquanto a latência sobe e o fornecedor piora em silêncio.
Continue por aqui
APIs & Resiliência
429 em API de IA: fila com concorrência e orçamento de tokens
Promise.all sobre quinhentos itens não é paralelismo, é um ataque. Como respeitar os dois limites que essas APIs impõem sem serializar tudo.
APIs & Resiliência
fetch failed com ECONNRESET no Node: undici, keep-alive e timeouts
A mensagem do fetch nativo não diz nada sozinha. O erro real está em err.cause, e a causa quase sempre é uma conexão reaproveitada tarde demais.
APIs & Resiliência
Cortando o custo de API de LLM: medir, rotear e cachear
A fatura triplicou sem o uso triplicar. Como atribuir custo por funcionalidade e quais alavancas realmente rendem, na ordem de retorno.