Blog

Timeout em cascata: por que o retry do cliente derruba o serviço que ia se recuperar

O banco de dados ficou lento por quarenta segundos. O incidente durou vinte e dois minutos. Entre uma coisa e outra existe um mecanismo que quase todo sistema distribuído carrega sem perceber: o cliente desiste antes do servidor terminar, tenta de novo, e a tentativa nova chega em cima do trabalho antigo que ninguém cancelou. Este artigo mostra por que o timeout do cliente e a duração real do trabalho no servidor são grandezas independentes, por que a soma dos timeouts em cadeia produz uma espera que ninguém configurou, como o retry multiplica carga exatamente no pior momento e por que backoff sozinho não conserta, qual sinal precisa atravessar a cadeia para o trabalho abandonado morrer junto, por que a política de tentativa precisa de um orçamento global em vez de um contador local, e quais testes reproduzem a cascata antes de ela acontecer em produção.

2026-08-22 / Arquitetura / 16 min

01

O timeout do cliente não interrompe o trabalho do servidor

A primeira coisa a entender é que timeout é uma decisão do lado que espera, não do lado que trabalha. Quando o cliente estoura três segundos e fecha a espera, o que acontece na prática é que ele para de ler a resposta. A consulta continua rodando no banco, a transação continua segurando o lock, o pool continua com a conexão ocupada, o handler continua alocando memória. Do ponto de vista do servidor, nada mudou: ele ainda está processando um pedido que já não tem destinatário. Esse trabalho órfão é a matéria-prima da cascata, porque ele consome recurso e não produz nem sucesso nem erro visível.

A consequência prática aparece na aritmética. Suponha um endpoint que normalmente responde em duzentos milissegundos, com timeout de cliente em três segundos e uma tentativa extra. Sob degradação, a resposta passa a levar oito segundos. O cliente desiste aos três, tenta de novo, e a segunda tentativa também desiste aos três. Ao fim de seis segundos, o cliente reporta falha e o servidor está com duas execuções vivas do mesmo pedido, ambas destinadas ao descarte. Se o volume de entrada for de mil requisições por segundo, o servidor não está processando mil, está processando algo entre duas e três mil, precisamente enquanto tenta se recuperar do que causou a lentidão inicial.

SEM PROPAGACAO DE CANCELAMENTO

cliente   |--espera 3s--|X (desiste, tenta de novo)
                        |--espera 3s--|X (desiste, reporta erro)

servidor  |=========trabalho 1 (8s, orfao apos 3s)=========|
                        |=========trabalho 2 (8s, orfao)=========|

                        ^ a partir daqui o servidor
                          processa 2x a carga real
                          e nenhum dos dois resultados
                          tem quem receba

COM PROPAGACAO DE CANCELAMENTO

cliente   |--espera 3s--|X
servidor  |==trabalho 1==|X  <- abortado junto, libera conexao e lock

Vale nomear o efeito com precisão, porque ele costuma ser confundido com sobrecarga comum. Sobrecarga é quando chega mais tráfego do que a capacidade. Aqui a carga externa não mudou: o que aumentou foi a carga interna gerada pelo próprio mecanismo de tolerância a falha. O sistema está se atacando com o comportamento que existia para protegê-lo, e por isso a lentidão inicial de quarenta segundos vira um incidente de vinte minutos: mesmo depois de o banco se recuperar, a fila de trabalho acumulada e as tentativas em voo mantêm a saturação por conta própria.

02

Timeouts em cadeia somam, e a soma ninguém configurou

Em uma arquitetura com mais de duas camadas, cada serviço costuma ter seu próprio timeout configurado de forma isolada, escolhido por quem escreveu aquele cliente HTTP naquela semana. O problema é que esses valores se compõem de maneira multiplicativa quando há retry, e não aditiva como a intuição sugere. Uma cadeia de três serviços, cada um com timeout de cinco segundos e duas tentativas, produz um pior caso de trinta segundos para uma requisição que o navegador desiste de esperar aos dez. Ninguém escreveu trinta em lugar nenhum: o número emergiu da composição.

CamadaTimeout localTentativasPior caso acumulado
Navegador ou app10s110s (é o único orçamento que o usuário percebe)
API de borda5s por chamada210s, já no limite do que o cliente aceita
Serviço de domínio5s por chamada220s, ninguém do lado de fora ainda está esperando
Banco ou serviço externo5s por consulta230s de trabalho que nunca teve destinatário

A correção não é escolher números menores por tentativa e erro, é inverter o sentido em que o valor é decidido. O timeout deixa de ser configuração local de cada cliente e passa a ser um orçamento que entra pela borda e é distribuído para dentro: a borda recebe dez segundos, gasta o que precisa em validação e roteamento e repassa o restante como prazo explícito para a próxima camada, que faz o mesmo. Cada serviço recebe quanto tempo ainda tem, em vez de decidir sozinho quanto tempo quer. A regra que fecha o desenho é simples de verificar: nenhuma camada pode prometer para baixo um prazo maior do que aquele que recebeu de cima.

// http/deadline.js
// Orcamento de tempo que atravessa a cadeia como prazo absoluto.
//
// Prazo absoluto, nao duracao relativa: se cada camada repassasse
// "voce tem 5s", o tempo ja gasto na camada anterior seria esquecido
// e o total cresceria a cada salto. Um instante de expiracao so pode
// diminuir conforme desce.

const DEADLINE_HEADER = 'x-deadline-ms';
// Margem para a resposta voltar pela rede antes de o chamador desistir.
// Sem ela, o servico termina exatamente no instante em que o cliente
// para de ouvir, e o trabalho e desperdicado no ultimo milissegundo.
const NETWORK_MARGIN_MS = 150;

export const deadlineFromRequest = (req, fallbackMs) => {
  const header = Number(req.headers[DEADLINE_HEADER]);
  // Nunca aceite um prazo maior que o fallback local: um cliente
  // que pede 60s nao pode alongar o orcamento do servico.
  if (Number.isFinite(header) && header > 0) {
    return Math.min(header, Date.now() + fallbackMs);
  }
  return Date.now() + fallbackMs;
};

export const remainingMs = (deadline) => deadline - Date.now();

// Prazo repassado para a proxima chamada: o que sobra, menos a margem.
// Retorna null quando ja nao ha tempo util, e o chamador deve falhar
// rapido em vez de iniciar um trabalho que nascera orfao.
export const childDeadline = (deadline, reserveMs = NETWORK_MARGIN_MS) => {
  const remaining = remainingMs(deadline) - reserveMs;
  return remaining > 0 ? Date.now() + remaining : null;
};

export const callDownstream = async (url, { deadline, signal, body }) => {
  const child = childDeadline(deadline);
  if (child === null) {
    const error = new Error('deadline_exceeded_before_call');
    error.code = 'DEADLINE_EXCEEDED';
    throw error;
  }

  // AbortSignal.any junta o cancelamento vindo de cima com o timeout local:
  // qualquer um dos dois aborta a chamada, e o trabalho para de verdade.
  const localTimeout = AbortSignal.timeout(remainingMs(child));
  const combined = AbortSignal.any([signal, localTimeout].filter(Boolean));

  return fetch(url, {
    method: 'POST',
    body: JSON.stringify(body),
    headers: {
      'content-type': 'application/json',
      [DEADLINE_HEADER]: String(child),
    },
    signal: combined,
  });
};

Um detalhe de implementação decide se isso funciona ou não: o prazo precisa viajar como instante de expiração, não como duração. Se cada camada repassa a string cinco segundos, o tempo já gasto pela camada anterior desaparece da conta e o orçamento é renovado a cada salto, que é exatamente o bug que se queria evitar. Repassando um instante, o valor só pode encolher. O custo dessa escolha é depender de relógios razoavelmente sincronizados entre os serviços, o que é aceitável dentro de um mesmo ambiente com NTP, mas exige cuidado ao atravessar a fronteira para um terceiro: nesse caso, converta para duração na saída e volte a instante na entrada.

03

Retry multiplica carga exatamente quando ela é escassa

Retry é uma aposta razoável quando a falha é independente e rara: um pacote perdido, um nó que reiniciou, um deploy trocando instâncias. Nesses casos a segunda tentativa acha um sistema saudável e o custo extra é irrelevante. A aposta deixa de valer quando a falha é correlacionada, e a degradação de um recurso compartilhado é o caso correlacionado por excelência. Se o banco está lento para todo mundo, todas as requisições estouram o timeout ao mesmo tempo e todas retentam ao mesmo tempo. A carga chega em bloco, num instante em que a capacidade disponível é justamente a menor do dia.

O multiplicador é maior do que aparenta porque ele se aplica em cada nível da cadeia. Três camadas com duas tentativas cada não geram duas chamadas ao banco, geram oito no pior caso, porque o retry da camada de cima reexecuta toda a subárvore de chamadas abaixo dela. É o que se chama de amplificação de retry, e é a razão pela qual uma política de tentativa aparentemente inofensiva por serviço vira uma bomba quando composta. A regra prática que evita o pior é permitir retry em apenas uma camada da cadeia, normalmente a mais próxima da borda, e desligá-lo nas demais.

  • Jitter não é opcional: backoff exponencial sem aleatoriedade só reagrupa as tentativas em ondas cada vez mais espaçadas, mas ainda sincronizadas, porque todos os clientes começaram a contar no mesmo instante.
  • Retry só faz sentido para erro que possa ter sido transitório: timeout, indisponibilidade, conflito de escrita. Um 400 ou um 422 vai falhar de novo, e retentá-lo é gastar capacidade para receber a mesma resposta.
  • Retry de operação que muda estado exige chave de idempotência, senão a tentativa que resolveu o timeout do cliente cobra duas vezes o cliente de verdade.
  • Um contador local por chamada não limita nada em cadeia, porque cada camada tem o seu. O que limita é um orçamento de tentativas que viaja junto do prazo e é decrementado a cada retry.
  • Retry precisa parar quando o prazo acabou: tentar de novo com trezentos milissegundos restantes é gastar capacidade para produzir um resultado que ninguém vai conseguir ler.
// http/retry-budget.js
// Retry com orcamento compartilhado, nao contador por chamada.
//
// Duas travas independentes:
//   1) prazo: nao inicia tentativa que nao cabe no tempo restante
//   2) orcamento por janela: sob falha correlacionada, o retry se
//      desliga sozinho antes de virar amplificador de carga

const RETRYABLE = new Set([408, 429, 502, 503, 504]);

// Teto proporcional ao trafego, nao numero fixo: em 1000 req/s com
// ratio 0.1, cabem 100 retries/s. Se a falha for correlacionada, o
// orcamento esvazia em segundos e as chamadas passam a falhar rapido.
export const createRetryBudget = ({ ratio = 0.1, windowMs = 10_000 } = {}) => {
  let attempts = 0;
  let retries = 0;
  let windowStart = Date.now();

  const roll = () => {
    if (Date.now() - windowStart < windowMs) return;
    windowStart = Date.now();
    attempts = 0;
    retries = 0;
  };

  return {
    recordAttempt: () => {
      roll();
      attempts += 1;
    },
    tryConsume: () => {
      roll();
      if (retries + 1 > attempts * ratio) return false;
      retries += 1;
      return true;
    },
    // Exponha isto como metrica: a queda para zero e o sinal de que
    // o sistema esta sob falha correlacionada, nao ruido isolado.
    available: () => {
      roll();
      return Math.max(0, Math.floor(attempts * ratio) - retries);
    },
  };
};

const sleep = (ms, signal) =>
  new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, ms);
    signal?.addEventListener(
      'abort',
      () => {
        clearTimeout(timer);
        reject(signal.reason);
      },
      { once: true },
    );
  });

export const withRetry = async (fn, { deadline, budget, signal, baseMs = 100 }) => {
  let lastError;

  for (let attempt = 0; attempt < 3; attempt += 1) {
    budget.recordAttempt();
    try {
      return await fn({ attempt });
    } catch (error) {
      lastError = error;

      const retryable = error.code === 'DEADLINE_EXCEEDED' || RETRYABLE.has(error.status);
      if (!retryable) throw error;

      // Jitter completo: sem ele, todos os clientes que falharam no
      // mesmo instante voltam juntos na mesma onda.
      const backoff = Math.random() * baseMs * 2 ** attempt;
      if (deadline - Date.now() <= backoff) throw error;
      if (!budget.tryConsume()) throw error;

      await sleep(backoff, signal);
    }
  }

  throw lastError;
};

04

Cancelamento precisa atravessar a cadeia inteira

Propagar o prazo resolve metade do problema: cada camada sabe quando parar de esperar. A outra metade é fazer o trabalho já iniciado morrer quando a espera acaba, e isso exige um sinal que desça junto da chamada. No ecossistema JavaScript esse sinal é o AbortSignal, encadeado do handler HTTP até a última chamada de rede. Em outras plataformas o nome muda mas o desenho é o mesmo: um contexto cancelável que acompanha a requisição e é verificado nos pontos de bloqueio.

O ponto que costuma ficar de fora é o banco de dados, e é justamente ele que segura o recurso mais escasso. Abortar a chamada no cliente HTTP não interrompe uma consulta que já está executando no servidor de banco: a conexão fica ocupada até a consulta terminar sozinha. Por isso a configuração precisa existir também do lado do banco, como tempo máximo de execução de comando, e não apenas como timeout do driver. Sem isso, uma consulta lenta continua consumindo a conexão do pool mesmo depois de todo o resto da cadeia ter desistido, e o pool esgotado transforma a lentidão de uma rota em indisponibilidade de todas as outras.

// db/query.js
// Cancelamento que chega ate o banco, nao so ate o driver.
//
// Sem statement_timeout, abortar do lado do cliente apenas devolve
// a conexao ao pool depois que a consulta terminar por conta propria.
// A consulta continua rodando no servidor e o recurso continua preso.

export const queryWithDeadline = async (pool, { sql, params, deadline, signal }) => {
  const remaining = deadline - Date.now();
  if (remaining <= 0) {
    const error = new Error('deadline_exceeded_before_query');
    error.code = 'DEADLINE_EXCEEDED';
    throw error;
  }

  const client = await pool.connect();
  // Nunca deixe a conexao vazar quando o abort chega no meio do caminho.
  const release = () => client.release();
  signal?.addEventListener('abort', release, { once: true });

  try {
    // statement_timeout e por sessao: aplicado na conexao emprestada,
    // vale para as consultas seguintes ate a conexao voltar ao pool.
    await client.query('SET LOCAL statement_timeout = $1', [Math.floor(remaining)]);
    return await client.query({ text: sql, values: params });
  } catch (error) {
    // 57014 = query_canceled no PostgreSQL. Traduzir aqui evita que a
    // camada de cima trate expiracao de prazo como erro de banco e
    // dispare um retry que so vai expirar de novo.
    if (error.code === '57014') {
      const timeout = new Error('query_timeout');
      timeout.code = 'DEADLINE_EXCEEDED';
      throw timeout;
    }
    throw error;
  } finally {
    signal?.removeEventListener('abort', release);
    release();
  }
};

Há um caso em que o cancelamento é a decisão errada, e vale reconhecê-lo: quando o trabalho já produziu efeito colateral parcial. Abortar no meio de uma sequência de escritas deixa o sistema num estado que ninguém desenhou. A saída aqui não é desistir do cancelamento, é mover a fronteira: a parte que muda estado vira uma unidade que ou completa ou não começa, e o cancelamento passa a acontecer antes dela ou depois dela, nunca no meio. Na prática, isso significa verificar o prazo restante logo antes de entrar na transação e tratar a transação como não interrompível uma vez iniciada.

05

Rejeitar cedo vale mais que enfileirar mais

Quando a chegada supera a capacidade, alguma requisição vai falhar. A única escolha real é onde e quando. Enfileirar sem limite transfere a falha para o futuro na forma de latência crescente, e é a pior das opções porque produz o trabalho órfão em escala: quando a requisição finalmente sai da fila, o cliente já desistiu há muito tempo. Uma fila com teto pequeno e rejeição imediata do excedente falha mais cedo e falha melhor, porque a rejeição é barata, é imediatamente visível para o chamador e não consome capacidade.

A checagem mais barata de todas custa uma comparação e evita todo o resto: antes de tirar um item da fila, verifique se o prazo dele ainda não expirou. Se expirou, descarte sem processar. Em uma fila saturada isso limpa sozinha a parte da carga que já não tem destinatário, e a capacidade volta a ser gasta em trabalho útil. É a diferença entre uma fila que se recupera quando a pressão diminui e uma que continua saturada por minutos depois, processando pedidos que ninguém está esperando.

Estratégia sob saturaçãoO que aconteceQuando usar
Fila ilimitadaLatência cresce sem teto, memória cresce junto, tudo vira trabalho órfãoNunca em caminho síncrono de requisição
Fila com teto e rejeiçãoExcedente falha em milissegundos e libera capacidade para o restoPadrão para qualquer serviço síncrono
Descarte por prazo expiradoTrabalho sem destinatário sai da fila sem consumir capacidadeSempre que houver prazo propagado
Disjuntor por dependênciaPara de tentar a dependência degradada e devolve erro imediatoFalha correlacionada persistente, não pico curto
Degradação funcionalDevolve resposta parcial ou de cache em vez de erroQuando existe resposta aceitável sem a dependência

O disjuntor merece uma ressalva porque costuma ser adotado como primeira medida quando deveria ser a última. Ele resolve falha correlacionada persistente, cortando tentativas contra uma dependência que já se sabe indisponível, mas é ruim para pico curto: o limiar dispara, o serviço passa a recusar tudo por alguns segundos e, ao voltar, manda a onda represada de uma vez. Prazo propagado, cancelamento efetivo e orçamento de retry resolvem a maior parte dos casos com menos efeito colateral. O disjuntor entra quando a dependência fica fora por minutos e continuar tentando é apenas queimar capacidade.

06

Provando que a cascata não acontece mais

Cascata é um comportamento emergente: cada componente parece correto isoladamente e o problema só aparece na composição sob pressão. Por isso teste unitário não pega, e por isso a validação precisa reproduzir a condição real, que é uma dependência lenta e não uma dependência quebrada. Injetar erro é fácil e não prova nada aqui, porque o erro rápido não gera trabalho órfão. O que prova é injetar latência.

  1. Injete latência na dependência mais profunda, algo entre três e cinco vezes o timeout configurado, mantendo o tráfego de entrada constante. A hipótese sob teste é que a carga na dependência permaneça igual à carga de entrada.
  2. Meça as chamadas efetivas que chegam na dependência durante a injeção. Se o número subir acima da entrada, existe amplificação de retry em algum ponto da cadeia e a primeira coisa a procurar é retry ligado em mais de uma camada.
  3. Meça o trabalho órfão diretamente: conte quantas respostas o servidor produziu para requisições cujo cliente já havia desistido. Um contador nesse ponto é a métrica mais honesta de propagação de cancelamento, porque só chega a zero quando o cancelamento realmente atravessa.
  4. Verifique o tempo de recuperação, que é o que separa quarenta segundos de vinte minutos. Remova a injeção e cronometre até a latência voltar ao normal. Se demorar muito mais do que a duração da injeção, sobrou trabalho acumulado que ninguém descartou.
  5. Confirme o pior caso ponta a ponta com um teste que soma prazos ao longo da cadeia e falha se o total ultrapassar o orçamento da borda. Esse teste é barato, roda no pipeline e pega a regressão no dia em que alguém aumentar um timeout local por conta própria.

Vale um alerta específico para a métrica que engana. A latência média cai durante uma cascata, porque as requisições que falham rápido por prazo expirado entram na conta como respostas curtas. Um painel de latência média fica verde no meio do incidente. O que enxerga a cascata é a razão entre chamadas de saída e requisições de entrada por dependência, que deve ficar próxima de um e cuja subida indica amplificação. O alerta certo é sobre essa razão, não sobre latência.

FAQ

Perguntas frequentes

Basta desligar o retry para resolver o problema?

Desligar resolve a amplificação e cria outro custo, então é uma troca e não uma correção. Sem retry, toda falha transitória vira erro visível para o usuário: um pacote perdido, uma instância reiniciando durante o deploy, uma reeleição de líder no banco. Essas falhas são frequentes o bastante para que a taxa de erro percebida suba de forma perceptível, e é por isso que o retry existe. A correção não é remover o mecanismo, é limitá-lo em três dimensões ao mesmo tempo. Primeiro, permitir retry em apenas uma camada da cadeia, normalmente a mais próxima da borda, porque é o retry composto que multiplica. Segundo, condicionar a tentativa ao prazo restante, para não iniciar uma chamada que já nasce sem tempo de terminar. Terceiro, submeter a tentativa a um orçamento proporcional ao tráfego, que se esgota rápido quando a falha é correlacionada e permanece disponível quando ela é isolada. Com as três travas, o retry continua cobrindo a falha rara e para de participar da cascata, que é exatamente a distinção que o contador local por chamada não consegue fazer.

Como escolher o timeout de cada camada sem chutar?

Escolhendo apenas um número e derivando o resto. O único valor que tem significado externo é o orçamento da borda, e ele vem do que o usuário e o cliente chamador toleram, tipicamente entre um e dez segundos dependendo da interação. Todos os demais são derivados de dentro desse orçamento, subtraindo o tempo que cada camada gasta em trabalho próprio e uma margem de rede, e nenhum deles pode ser maior do que o que recebeu de cima. Os valores locais servem só como teto de segurança para o caso de a requisição chegar sem prazo declarado, e nesse papel devem ser calibrados a partir do p99 observado por rota com uma folga, nunca de um número redondo escolhido por hábito. Um limiar acima do p99 real nunca dispara e não protege nada, e um limiar abaixo do p95 transforma tráfego saudável em erro. Vale ainda separar o timeout de conexão do timeout total: falha em estabelecer conexão aparece em centenas de milissegundos e não deve consumir o orçamento inteiro esperando por algo que já se sabe indisponível.

Prazo propagado funciona quando a chamada passa por uma fila?

Funciona, e é justamente onde ele evita mais desperdício, mas exige duas mudanças em relação à chamada síncrona. A primeira é que o prazo precisa ser gravado no próprio envelope da mensagem, não no cabeçalho da requisição que a produziu, porque o consumidor pode processá-la minutos depois e não tem outra forma de saber quanto tempo restava. A segunda é que o consumidor precisa verificar esse prazo antes de começar o trabalho, e descartar a mensagem expirada em vez de processá-la, contabilizando o descarte numa métrica própria para que ele fique visível. Há uma diferença importante entre fila síncrona e assíncrona: quando existe alguém esperando do outro lado, o descarte por prazo é correto e barato. Quando a fila é de trabalho em segundo plano e ninguém está esperando, o prazo não deve ser o do chamador original e sim um limite de validade do negócio, definido pela operação em si, porque descartar uma cobrança pendente porque ela ficou dez segundos na fila seria trocar um problema de latência por perda de trabalho.

A recuperação começa quando o sistema para de se atacar

A cascata não é um bug em um componente específico, é uma propriedade que emerge quando timeout, retry e fila são configurados isoladamente e se compõem sob pressão. O sistema fica indisponível por muito mais tempo do que a falha original durou, e a causa é o próprio mecanismo que existia para tolerar falha. Posso revisar a cadeia de chamadas do seu sistema e definir o orçamento de prazo que entra pela borda e desce até o banco, a propagação de cancelamento que mata o trabalho órfão, a política de retry limitada por orçamento em vez de contador local, a estratégia de rejeição sob saturação e os testes de latência injetada que provam que a cascata não volta.