Blog

Timeout mal calibrado: quando tentar de novo piora o incidente

O timeout configurado era de trinta segundos e a mediana da chamada era de oitenta milissegundos. Ninguém escolheu esse número: ele veio do valor padrão da biblioteca HTTP e sobreviveu a quatro anos de deploys porque nunca causou um alerta. No dia em que a dependência degradou, cada requisição ficou trinta segundos ocupando uma conexão do pool, o pool esgotou em menos de um minuto e o serviço inteiro caiu por causa de uma dependência que ainda respondia a maior parte das chamadas. Este artigo mostra como derivar o valor do timeout a partir da distribuição de latência em vez de arredondar para um número confortável, por que o orçamento de retry precisa ser uma fração do tráfego e não um contador por requisição, qual conta define quantas tentativas cabem dentro do prazo do usuário, quando a requisição paralela antecipada resolve a cauda longa e quando ela dobra a fatura sem melhorar nada, e qual métrica prova que a calibração está certa antes do próximo incidente.

2026-08-28 / Arquitetura / 16 min

01

O timeout não é uma margem de segurança, é uma declaração de desistência

A intuição por trás de um timeout generoso é que ele evita cortar uma requisição que ainda ia dar certo. Essa intuição trata o timeout como uma rede de proteção, e é por isso que os valores tendem a subir com o tempo: cada vez que alguém vê um erro de prazo esgotado em um caso legítimo, o reflexo é aumentar o número. O que essa leitura ignora é que o timeout não protege a requisição individual, ele protege o recurso compartilhado que a requisição está segurando enquanto espera. Uma conexão do pool, uma thread do servidor, um slot de concorrência do cliente HTTP, memória com o corpo da requisição carregado.

A conta que torna isso concreto é a Lei de Little aplicada ao pool de conexões. Se o serviço recebe cem requisições por segundo, cada uma segura uma conexão pelo tempo de resposta da dependência, e o pool tem cinquenta conexões, então o tempo máximo de resposta que o sistema tolera sem esgotar o pool é de meio segundo. Com timeout de trinta segundos, basta que a dependência fique lenta por alguns segundos para que todas as cinquenta conexões estejam ocupadas esperando, e a partir daí o serviço rejeita tráfego que não tem nada a ver com aquela dependência. O timeout generoso não deu chance à requisição lenta, ele derrubou todas as outras.

Reformulando: o timeout responde a uma pergunta de negócio, não de infraestrutura. A pergunta é a partir de quanto tempo essa resposta deixa de ter valor para quem pediu. Para uma consulta de saldo em tela de checkout, isso é algo entre um e dois segundos, porque depois disso o usuário já decidiu que o site quebrou. Para um job noturno de reconciliação, isso pode ser dez minutos. Os dois casos podem chamar exatamente a mesma dependência, e configurar o mesmo timeout para os dois é um erro nas duas direções ao mesmo tempo.

Sintoma observadoDiagnóstico errado comumCausa realAjuste correto
Pool de conexões esgotado sob dependência lentaPool pequeno demaisTimeout maior que o tempo de ocupação sustentávelReduzir timeout para o p99 mais margem, não aumentar o pool
Erro de prazo esgotado em requisição que ia funcionarTimeout curto demaisTimeout abaixo do p99 real da dependênciaMedir a distribuição e recalibrar sobre o percentil
Dependência não se recupera depois do picoDependência sem capacidadeRetry do cliente multiplicando a carga durante a degradaçãoOrçamento de retry como fração do tráfego
Latência do usuário muito acima da soma das etapasRede lentaTentativas sequenciais consumindo o prazo do usuárioPrazo propagado com desconto a cada salto
Fatura da dependência sobe sem aumento de tráfegoPreço reajustadoRequisição paralela antecipada disparando sempreDisparar a segunda cópia só acima do p95

02

Derivar o número da distribuição, não do número redondo

O procedimento que substitui o chute tem três entradas: a distribuição de latência da dependência em condição saudável, o prazo que o chamador tem disponível, e a decisão sobre quantas tentativas cabem dentro desse prazo. A primeira entrada exige histograma e não média. A média de latência de uma dependência com cauda longa é um número que quase nenhuma requisição real experimenta, e calibrar sobre ela produz um timeout que corta uma fatia grande do tráfego legítimo.

A regra prática que funciona é fixar o timeout de uma tentativa individual no percentil noventa e nove da latência saudável, multiplicado por um fator entre um vírgula três e um vírgula cinco. O percentil noventa e nove define o ponto a partir do qual a espera deixou de ser comportamento normal e passou a ser sinal de que algo está errado com aquela chamada específica. O fator de folga absorve a variação legítima de carga entre o dia da medição e o dia do incidente, sem chegar perto de um número que permita ocupação prolongada do pool.

Um detalhe que muda o resultado é que o timeout precisa ser dividido em fases, porque um timeout único de conexão mais leitura esconde onde o tempo foi gasto. Falha de conexão é sinal de instância indisponível e deve ser rápida, tipicamente algumas centenas de milissegundos, porque nesse caso tentar outra instância imediatamente é a resposta certa. Falha de leitura é sinal de que o servidor aceitou o trabalho e está demorando, e nesse caso repetir a chamada provavelmente vai encontrar o mesmo servidor no mesmo estado. Tratar os dois com o mesmo número faz o cliente repetir o que não deveria e desistir do que deveria esperar.

// http/calibragem.js
// Deriva o timeout da distribuicao medida, nao de um numero redondo.
// A entrada e o histograma de latencia SAUDAVEL da dependencia, coletado
// numa janela sem incidente. Calibrar sobre janela com degradacao
// incorporada infla o p99 e produz um timeout que nunca protege nada.

const FOLGA_P99 = 1.4;        // absorve variacao de carga entre medicao e producao
const TETO_CONEXAO_MS = 400;  // falha de conexao e binaria: a instancia responde ou nao

/**
 * @param {object} perfil
 * @param {number} perfil.p99Ms            p99 da latencia saudavel, fim a fim
 * @param {number} perfil.prazoUsuarioMs   quanto tempo a resposta ainda vale
 * @param {number} perfil.tentativas       quantas tentativas se deseja permitir
 */
export const calibrarTimeout = ({ p99Ms, prazoUsuarioMs, tentativas = 2 }) => {
  const porTentativa = Math.ceil(p99Ms * FOLGA_P99);

  // O prazo do usuario e o teto absoluto: nao adianta permitir tres
  // tentativas de 250ms se o checkout desiste em 600ms. A divisao reserva
  // uma fatia para overhead de rede e serializacao entre as tentativas.
  const overheadPorTentativa = 30;
  const tetoPorTentativa = Math.floor(
    (prazoUsuarioMs - overheadPorTentativa * tentativas) / tentativas,
  );

  if (tetoPorTentativa < porTentativa) {
    // O prazo nao comporta o numero de tentativas pedido. Reduzir o timeout
    // ate caber e a escolha errada: ele passaria a cortar trafego legitimo.
    // O ajuste correto e reduzir tentativas, e essa decisao fica explicita.
    const tentativasPossiveis = Math.floor(
      prazoUsuarioMs / (porTentativa + overheadPorTentativa),
    );

    return {
      conexaoMs: Math.min(TETO_CONEXAO_MS, porTentativa),
      leituraMs: porTentativa,
      tentativas: Math.max(1, tentativasPossiveis),
      alerta: `prazo de ${prazoUsuarioMs}ms comporta ${Math.max(1, tentativasPossiveis)} tentativa(s), nao ${tentativas}`,
    };
  }

  return {
    conexaoMs: Math.min(TETO_CONEXAO_MS, porTentativa),
    leituraMs: porTentativa,
    tentativas,
    alerta: null,
  };
};

// Consulta de saldo no checkout: p99 de 180ms, usuario desiste em 1500ms.
// -> conexao 252ms, leitura 252ms, 2 tentativas, ainda com folga no prazo.
// Com o timeout padrao de 30s da biblioteca, uma unica tentativa lenta
// consome vinte vezes o prazo que o usuario aceita esperar.

O caso interessante nessa função é o ramo que emite alerta. Quando o prazo do usuário não comporta o número de tentativas pedido, existe a tentação de resolver reduzindo o timeout de cada tentativa até caber. Esse é o movimento que transforma uma configuração razoável em uma máquina de erros: o timeout passa a cortar tráfego que estava saudável, o cliente enxerga falha, o retry dispara, e o sistema gera carga extra para chamadas que teriam funcionado. A escolha correta é reduzir o número de tentativas e deixar isso explícito, porque essa é uma decisão de produto sobre quanto o usuário espera, não um parâmetro de infraestrutura.

03

Retry por requisição multiplica carga, retry por orçamento não

A configuração clássica de três tentativas por requisição parece conservadora quando você olha uma requisição isolada. Ela deixa de ser conservadora no exato momento em que a taxa de erro sobe, porque nesse momento a carga sobre a dependência degradada é multiplicada por três. Uma dependência que estava a oitenta por cento de utilização e começou a errar dez por cento das chamadas recebe, com retry ingênuo, cento e vinte por cento da carga original. Ela não vai se recuperar sozinha, porque o cliente está adicionando carga na proporção exata da degradação.

A correção é tratar o retry como um recurso escasso compartilhado entre todas as requisições, e não como um direito individual. O padrão de orçamento de retry mantém uma janela deslizante com a contagem de requisições originais e de tentativas adicionais, e só autoriza uma nova tentativa se a proporção estiver abaixo de um teto, tipicamente dez por cento. O efeito prático é que quando a taxa de falha é baixa, praticamente todo erro é repetido e o usuário nem percebe. Quando a taxa de falha explode, o orçamento se esgota nos primeiros instantes e o cliente para de repetir, entregando erro rápido em vez de amplificar o incidente.

// resiliencia/orcamento-retry.js
// Retry como recurso compartilhado, nao como direito de cada requisicao.
// Janela deslizante: a razao entre tentativas extras e requisicoes originais
// nunca passa do teto. Sob falha generalizada o orcamento seca sozinho.

export const criarOrcamentoRetry = ({
  janelaMs = 10000,
  razaoMaxima = 0.1,     // no maximo 10% de carga extra sobre o trafego original
  minimoPorSegundo = 3,  // permite retry mesmo em rota de trafego muito baixo
  agora,
}) => {
  const originais = [];
  const extras = [];

  const podar = (t) => {
    const corte = t - janelaMs;
    while (originais.length && originais[0] <= corte) originais.shift();
    while (extras.length && extras[0] <= corte) extras.shift();
  };

  return {
    registrarOriginal() {
      const t = agora();
      podar(t);
      originais.push(t);
    },

    /**
     * Autoriza (ou nao) mais uma tentativa. Consome o orcamento quando
     * autoriza, para que duas requisicoes concorrentes nao gastem o mesmo
     * saldo. Retornar false rapido e uma resposta valida, nao uma falha.
     */
    tentarConsumir() {
      const t = agora();
      podar(t);

      const piso = (minimoPorSegundo * janelaMs) / 1000;
      const teto = Math.max(piso, originais.length * razaoMaxima);

      if (extras.length >= teto) return false;

      extras.push(t);
      return true;
    },

    estado() {
      const t = agora();
      podar(t);
      return {
        originais: originais.length,
        extras: extras.length,
        razao: originais.length ? extras.length / originais.length : 0,
      };
    },
  };
};

// Erros que NAO devem consumir orcamento nem gerar tentativa: qualquer
// resposta que o servidor produziu deliberadamente sobre o conteudo da
// requisicao. Repetir um 400 gera carga garantida sem chance de sucesso.
const REPETIVEL = new Set([408, 429, 500, 502, 503, 504]);

export const ehRepetivel = (erro) => {
  if (erro.codigo === 'ECONNREFUSED' || erro.codigo === 'ETIMEDOUT') return true;
  if (erro.status === undefined) return false;
  return REPETIVEL.has(erro.status);
};

O piso de tentativas por segundo existe por um motivo que só aparece em serviços de baixo tráfego. Com dez por cento de razão máxima e uma rota que recebe três requisições por minuto, o orçamento nunca autorizaria nenhuma tentativa, porque dez por cento de um número muito pequeno arredonda para zero. O piso garante que a proteção contra amplificação não vire proibição total de retry em rotas pouco usadas, que são justamente as que mais se beneficiam de uma segunda chance.

A segunda peça é a separação entre erro repetível e erro determinístico. Repetir uma resposta de quatrocentos e um ou de quatrocentos e vinte e dois é gasto garantido sem chance de sucesso, porque o servidor avaliou a requisição e a recusou pelo conteúdo. O caso ambíguo é o quatrocentos e vinte e nove, que é uma recusa deliberada mas que carrega a instrução de quando tentar de novo. Ele deve ser repetido, mas honrando o cabeçalho de espera indicado pelo servidor, e nunca com o intervalo calculado pelo cliente.

04

O prazo precisa atravessar a cadeia com desconto

A calibração de um serviço isolado não sobrevive à composição. Quando a requisição do usuário atravessa quatro serviços e cada um define seu próprio timeout localmente, o resultado é uma soma que ninguém configurou e que quase sempre excede o prazo do usuário por uma margem grande. O problema fica visível no comportamento mais desperdiçador possível: o serviço mais profundo da cadeia continua processando uma requisição cujo chamador já desistiu, ocupando recurso para produzir uma resposta que ninguém vai ler.

A correção é propagar o prazo absoluto e não o tempo restante relativo. Cada serviço recebe um instante limite, subtrai o tempo que já consumiu localmente, e passa adiante um prazo menor. Antes de iniciar qualquer chamada, verifica se o prazo restante é suficiente para que a chamada tenha chance de terminar; se não for, falha imediatamente sem tocar na dependência. Essa verificação prévia é a que produz o maior ganho durante um incidente, porque ela transforma trabalho garantidamente inútil em erro instantâneo.

Sem propagacao de prazo (cada servico com timeout local de 3s)

  usuario  desiste em 2s
     |
     v
  gateway ----3s----> pedidos ----3s----> estoque ----3s----> fornecedor
     |                   |                   |                    |
   [2s] X                |                   |                    |
   usuario ja foi        v                   v                    v
                    ainda trabalhando   ainda trabalhando   ainda trabalhando
                    ate 9s no pior caso, produzindo resposta para ninguem

Com prazo absoluto propagado (limite = t0 + 1900ms)

  t0         gateway consome 40ms   -> restante 1860ms
  t0+40ms    pedidos consome 120ms  -> restante 1740ms
  t0+160ms   estoque precisa de 250ms (p99 do fornecedor) -> cabe, segue
  t0+410ms   fornecedor responde
  t0+520ms   resposta chega ao usuario, dentro do prazo

Caso de corte antecipado

  t0+1800ms  estoque recebe requisicao com restante de 100ms
             p99 do fornecedor e 250ms -> nao cabe
             falha imediata, fornecedor nem chega a ser chamado
             economia: uma chamada de 250ms que seria descartada
// contexto/prazo.js
// Prazo ABSOLUTO propagado pela cadeia. Relativo nao funciona: o "restante"
// calculado no gateway ja esta velho quando chega no terceiro salto.

export const criarPrazo = ({ limiteEm, agora }) => ({
  limiteEm,
  restanteMs: () => Math.max(0, limiteEm - agora()),
  expirou: () => agora() >= limiteEm,
});

export const prazoDoCabecalho = (cabecalhos, { agora, padraoMs }) => {
  // Formato absoluto em epoch ms. Preferido a um "x-timeout-ms" relativo
  // porque nao acumula o tempo de rede de cada salto como erro.
  const bruto = cabecalhos['x-deadline-epoch-ms'];
  const analisado = bruto ? Number(bruto) : NaN;

  if (!Number.isFinite(analisado)) {
    return criarPrazo({ limiteEm: agora() + padraoMs, agora });
  }

  // Teto local: um chamador nao pode pedir um prazo maior do que este
  // servico esta disposto a segurar recurso. Sem esse teto, um cliente
  // mal configurado mantem conexoes ocupadas pelo tempo que quiser e
  // anula toda a calibragem feita aqui dentro.
  return criarPrazo({ limiteEm: Math.min(analisado, agora() + padraoMs), agora });
};

/**
 * Executa a chamada so se o prazo restante comporta o p99 dela.
 * Este e o corte que mais economiza durante incidente: trabalho que
 * seria descartado no fim nem chega a comecar.
 */
export const chamarComPrazo = async (prazo, { p99Ms, executar, nome }) => {
  const restante = prazo.restanteMs();

  if (restante < p99Ms) {
    const erro = new Error(`prazo insuficiente para ${nome}`);
    erro.codigo = 'DEADLINE_INSUFICIENTE';
    erro.restanteMs = restante;
    erro.necessarioMs = p99Ms;
    throw erro;
  }

  const controlador = new AbortController();
  const disparo = setTimeout(() => controlador.abort(), restante);

  try {
    return await executar({
      sinal: controlador.signal,
      timeoutMs: restante,
      cabecalhos: { 'x-deadline-epoch-ms': String(prazo.limiteEm) },
    });
  } finally {
    clearTimeout(disparo);
  }
};

O teto local na leitura do cabeçalho é a parte que costuma faltar. Sem ele, o serviço confia no prazo que o chamador enviou, e um cliente mal configurado que pede sessenta segundos consegue manter conexões ocupadas por sessenta segundos, o que anula toda a calibração feita internamente. O prazo propagado é um limite superior negociável para baixo, nunca um pedido que o serviço é obrigado a atender.

05

Requisição paralela antecipada: quando a cauda justifica a cópia

Existe uma classe de latência que nenhuma calibração de timeout resolve: a cauda que não vem de degradação sistêmica e sim de variação individual. Uma instância que acabou de sofrer uma pausa de coletor de lixo, uma que perdeu o cache local depois de reiniciar, uma que caiu num nó com vizinho barulhento. Nesses casos a chamada específica é lenta enquanto o serviço como um todo está saudável, e esperar o timeout para depois repetir significa somar o tempo de espera ao tempo da nova tentativa.

A técnica que ataca isso é disparar uma segunda cópia da requisição antes de a primeira falhar, aceitando a primeira resposta que chegar e cancelando a outra. O ponto crítico é o gatilho: disparar a cópia imediatamente dobra o tráfego e a fatura para ganhar quase nada, porque a maioria das chamadas termina rápido. Disparar no percentil noventa e cinco significa que apenas cinco por cento das chamadas geram uma segunda requisição, o custo extra fica na mesma ordem de grandeza, e a cauda acima do p95 passa a ser cortada pela cópia.

EstratégiaCusto extra de requisiçõesEfeito no p99 do usuárioQuando não usar
Retry só depois do timeoutApenas em caso de erroPiora: soma o timeout inteiro à nova tentativaNunca abrir mão, é a rede de segurança final
Cópia paralela imediataCem por centoMelhora bastanteQuase sempre, o custo raramente compensa
Cópia disparada no p95Cerca de cinco por centoMelhora, corta a cauda de variação individualOperações com efeito colateral não idempotente
Cópia disparada no p99Cerca de um por centoMelhora pouco, atua tarde demaisQuando o alvo do trabalho é justamente o p99

A restrição que elimina a técnica em boa parte dos casos é a idempotência. Duas cópias de uma leitura são inofensivas, duas cópias de uma cobrança são um incidente financeiro. Mesmo com cancelamento implementado, a corrida existe: a segunda cópia pode chegar ao servidor e ser processada antes de o cancelamento ser observado. Por isso a cópia paralela só é aplicável a operações de leitura ou a escritas protegidas por chave de idempotência de ponta a ponta.

Vale registrar também a interação com o orçamento de retry. A cópia antecipada é carga adicional pela mesma lógica que o retry, e precisa consumir o mesmo orçamento. Um sistema com orçamento de retry bem configurado mas com cópia antecipada fora dele continua amplificando carga durante o incidente, só que por um caminho que o painel de retry não mostra.

06

Provar a calibração antes do incidente

A calibração é uma hipótese sobre o comportamento do sistema sob uma condição que ainda não aconteceu, e existem exatamente três verificações que a transformam em fato. A primeira é comparar o timeout configurado com o p99 medido de forma automática e contínua, porque a latência da dependência muda com o tempo e um timeout que estava certo há seis meses pode ter ficado abaixo do p99 atual sem que ninguém tenha mexido em nada.

A segunda é medir o tempo de ocupação do pool sob a hipótese de degradação, e não sob tráfego normal. Isso é um teste de carga com a dependência substituída por um duplo que responde no timeout configurado em vez de na latência normal. O critério de aprovação é simples: com a dependência em degradação total, o serviço continua respondendo às rotas que não dependem dela. Se o pool esgota e rotas independentes começam a falhar, a calibração está errada, independentemente de o número parecer razoável.

// test/calibragem-timeout.test.js
// Verifica as duas propriedades que a calibragem promete: o timeout nao
// corta trafego saudavel, e a degradacao da dependencia nao vira carga
// extra sobre ela justamente quando ela tem menos capacidade.

import { calibrarTimeout } from '../src/http/calibragem.js';
import { criarOrcamentoRetry } from '../src/resiliencia/orcamento-retry.js';

describe('calibragem de timeout', () => {
  it('mantem o timeout acima do p99 medido da dependencia', () => {
    // Valores vindos do histograma de producao da ultima semana saudavel.
    const perfil = { p99Ms: 180, prazoUsuarioMs: 1500, tentativas: 2 };
    const config = calibrarTimeout(perfil);

    expect(config.leituraMs).toBeGreaterThan(perfil.p99Ms);
    expect(config.alerta).toBeNull();
  });

  it('reduz tentativas em vez de encurtar o timeout quando o prazo aperta', () => {
    // Prazo curto: nao cabem 3 tentativas de 252ms. A resposta correta e
    // avisar e reduzir tentativas, nunca comprimir o timeout ate caber.
    const config = calibrarTimeout({ p99Ms: 180, prazoUsuarioMs: 600, tentativas: 3 });

    expect(config.leituraMs).toBeGreaterThan(180);
    expect(config.tentativas).toBeLessThan(3);
    expect(config.alerta).toContain('comporta');
  });

  it('esgota o orcamento de retry quando a falha e generalizada', () => {
    let relogio = 0;
    const orcamento = criarOrcamentoRetry({
      janelaMs: 10000,
      razaoMaxima: 0.1,
      minimoPorSegundo: 3,
      agora: () => relogio,
    });

    // 1000 requisicoes originais, todas falhando. Sem orcamento seriam
    // 1000 tentativas extras, ou seja, 100% de carga adicional sobre a
    // dependencia que ja esta caindo. Com orcamento, o teto e 100.
    let autorizadas = 0;
    for (let i = 0; i < 1000; i += 1) {
      relogio += 5;
      orcamento.registrarOriginal();
      if (orcamento.tentarConsumir()) autorizadas += 1;
    }

    expect(autorizadas).toBeLessThanOrEqual(100);
    expect(orcamento.estado().razao).toBeLessThanOrEqual(0.1);
  });

  it('nao bloqueia retry em rota de trafego muito baixo', () => {
    let relogio = 0;
    const orcamento = criarOrcamentoRetry({
      janelaMs: 10000,
      razaoMaxima: 0.1,
      minimoPorSegundo: 3,
      agora: () => relogio,
    });

    // 2 requisicoes na janela: 10% arredondaria para zero tentativas.
    // O piso garante que a rota pouco usada ainda tem segunda chance.
    relogio += 100;
    orcamento.registrarOriginal();
    relogio += 100;
    orcamento.registrarOriginal();

    expect(orcamento.tentarConsumir()).toBe(true);
  });
});

A terceira verificação fecha o ciclo e é observacional em vez de testável: acompanhar a razão entre tentativas extras e requisições originais como métrica de primeira classe no painel. Ela tem uma propriedade útil que a taxa de erro não tem. A taxa de erro sobe quando o incidente já está acontecendo, enquanto a razão de retry sobe no minuto anterior, quando a dependência começou a degradar mas o retry ainda está mascarando isso do usuário. Um alerta sobre essa razão dá o tempo de reação que a taxa de erro não dá.

  • Timeout de conexão e de leitura são configurados separadamente, porque a resposta correta para cada falha é diferente.
  • O timeout de leitura é derivado do p99 medido e recalculado quando o histograma muda, não fixado numa constante do código.
  • O orçamento de retry é global por dependência, e a cópia paralela antecipada consome o mesmo orçamento.
  • O prazo é absoluto, propagado por cabeçalho e limitado por um teto local em cada serviço que o recebe.
  • Antes de chamar a dependência, o serviço compara o prazo restante com o p99 dela e falha rápido quando não cabe.
  • A razão de retry está no painel com alerta próprio, porque ela se move antes da taxa de erro.

FAQ

Perguntas frequentes

Qual timeout usar quando a dependência não tem histórico de latência medido?

A ausência de medição é o problema a resolver primeiro, e existe um valor provisório que evita tanto o corte de tráfego legítimo quanto a ocupação prolongada do pool enquanto a medição não existe. O ponto de partida é derivar do prazo do usuário e não da dependência: se o chamador tem um segundo e meio, uma tentativa de setecentos milissegundos com uma segunda dentro do que sobra é uma configuração defensável sem nenhum dado sobre a dependência, porque ela respeita o único número que você conhece com certeza. Junto disso, instrumente a chamada com histograma desde o primeiro dia, não com média, e revise o valor depois de uma semana de tráfego real. O erro que precisa ser evitado nesse período é adotar o padrão da biblioteca HTTP, que costuma ficar entre dez e sessenta segundos e existe para não surpreender quem baixa arquivos grandes, não para proteger um serviço interativo. Se a dependência é externa e tem contrato de nível de serviço publicado, o número do contrato serve como teto de sanidade, mas raramente como valor de configuração, porque ele descreve o pior caso aceitável comercialmente e não a latência típica.

Circuit breaker substitui o orçamento de retry?

Não, e a confusão entre os dois deixa uma lacuna que aparece exatamente no cenário mais comum de degradação. O disjuntor atua sobre a decisão binária de continuar chamando ou parar de chamar uma dependência, e ele funciona bem quando a falha é total ou quase total, porque nesse caso a taxa de erro cruza o limiar e o circuito abre. O orçamento de retry atua sobre a amplificação, e é ele que protege no cenário de degradação parcial, quando a dependência erra dez ou quinze por cento das chamadas: essa taxa costuma ficar abaixo do limiar do disjuntor, o circuito permanece fechado, e sem orçamento cada erro vira três chamadas até que a degradação parcial se torne total por efeito do próprio cliente. Na prática os dois convivem em camadas: o orçamento limita a carga extra enquanto a dependência ainda atende a maior parte do tráfego, e o disjuntor corta a comunicação quando ela deixou de atender. Vale acrescentar que o disjuntor precisa de um estado intermediário que deixa passar poucas chamadas de teste, senão a recuperação nunca é detectada e o circuito só fecha por tempo decorrido, o que costuma reabrir na primeira rajada.

Como calibrar timeout de chamadas para modelos de linguagem, onde a variância é enorme?

Chamadas para modelos generativos quebram a premissa central do método porque a latência não é uma propriedade da dependência e sim do tamanho da saída, o que torna o p99 agregado quase inútil como base de cálculo. O ajuste que funciona é parar de calibrar sobre o tempo total e passar a calibrar sobre o tempo até o primeiro token, que é a métrica estável e que de fato reflete a saúde do provedor, complementada por um timeout de inatividade entre tokens em vez de um teto para a resposta inteira. Uma resposta longa que está sendo transmitida continuamente é uma resposta saudável mesmo aos quarenta segundos, enquanto uma pausa de oito segundos sem nenhum token é sinal de problema mesmo que o total ainda esteja dentro do limite. Junto disso, o prazo total precisa existir como teto absoluto derivado do produto, e é onde o limite de tokens de saída entra como instrumento de controle de latência, porque ele é o único parâmetro que limita o pior caso de forma previsível. Retry nesse contexto também muda de natureza: repetir uma geração custa a chamada inteira de novo e frequentemente produz uma saída diferente, então a decisão de repetir precisa considerar custo e não só latência, e em muitos casos a resposta certa é cair para um modelo mais rápido em vez de repetir no mesmo.

O número do timeout é uma decisão de produto disfarçada de configuração

O valor padrão da biblioteca sobrevive anos porque nunca causa alerta em condição normal, e cobra tudo de uma vez no dia em que a dependência degrada. A saída não é escolher um número menor no chute, é derivar o número da distribuição medida, limitar o retry por orçamento compartilhado e propagar o prazo com desconto por toda a cadeia. Posso medir a distribuição de latência das suas dependências e recalibrar os timeouts sobre o p99 real, implementar o orçamento de retry por dependência com separação entre erro repetível e determinístico, introduzir a propagação de prazo absoluto com corte antecipado nos serviços mais profundos, e deixar no painel a razão de retry com alerta que dispara antes da taxa de erro.