Pular para o conteúdo
upgbp

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.

7 min de leitura

O erro tratado aqui

HTTP 503 Service Unavailable: upstream connect error or disconnect/reset before headers

Ambiente testado

  • Node 22
  • TypeScript 5.8
  • Next.js 15.3
Neste artigo (10)

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

logs da aplicaçãoexit 1
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 headers

Quatro 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.

Duas linhas do tempo com sete clientes cada. Sem jitter, as repetições se alinham em quatro colunas verticais; com full jitter, os mesmos pontos aparecem espalhados ao longo do eixo de tempo.
As mesmas 28 tentativas. Sem jitter elas chegam em quatro ondas; com full jitter, distribuídas.

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

  1. Classificar o erro antes de decidir.

    src/lib/resiliencia/classificar.ts
    export 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 };
    }
  2. 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 });
      });
  3. Trabalhar com prazo, não com contagem.

    src/lib/resiliencia/fetch-resiliente.ts
    import { 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.any combina os dois cortes: o da tentativa individual e o do prazo global. O primeiro que disparar vence.

  4. 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.ts
    type 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;
        }
      }
    }
  5. Combinar os dois e ter um plano para quando falhar.

    src/lib/frete.ts
    import { 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:

logs da aplicação
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 fixa

Rodando 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