Blog

Timeout em cascata entre serviços: quando o tempo de espera de um vira a fila do outro

Um serviço A chama o serviço B com timeout de dois segundos. Em horário de pico, B satura, as requisições passam a esperar na fila interna dele e A começa a devolver 504. O incidente parece simples: B está lento. O problema real é que a espera de A virou a fila de B. Quando A desiste, B não sabe disso e continua executando pedidos cujo resultado ninguém vai ler, consumindo CPU, conexões e cotas de terceiros. Este artigo mostra por que o timeout por salto não basta, como o prazo precisa descer pela cadeia como orçamento restante, como uma fila com prazo descarta trabalho morto em vez de executá-lo e como testar essa propriedade com carga acima da capacidade antes de a produção fazer isso por você.

2026-10-12 / Arquitetura / 12 min

01

A espera de quem chama vira a fila de quem atende

Quando A define um timeout de dois segundos para a chamada a B, ele declara até quando quer esperar, mas não declara o que B deve fazer. Essa diferença decide o custo do incidente. Se B tem capacidade, a espera de A é só latência. Se B está saturado, a espera de A acontece dentro da fila de B: o pedido entra, aguarda um slot livre e só então executa. Para A, o tempo parado é o mesmo tempo que o pedido passa na fila de B. Cada timeout que dispara em A deixa, portanto, um item ocupando a fila de B sem que ninguém o esteja esperando.

QuemO que medeO que acontece no timeoutRisco sem propagação
ClienteTempo até a respostaRecebe erro e pode tentar de novoA retentativa soma um item novo à fila
Serviço AEspera pela resposta de BCancela a própria espera e devolve 504Libera o slot de A, mas não o de B
Serviço BTempo de execução do pedidoNão sabe que houve timeoutExecuta trabalho cujo resultado será descartado

02

Por que a fila cresce mesmo com clientes desistindo

A lei de Little relaciona a quantidade média de itens no sistema com a taxa de chegada e o tempo de permanência: L = λ × W. Em B, W inclui a espera na fila, e essa espera é exatamente o que o timeout de A deveria limitar. Sem propagação, A corta a espera do lado dele, mas B continua tratando o item como trabalho legítimo. Quando o cliente tenta de novo, λ sobe justamente quando a fila já está cheia de trabalho morto. Por fora, o sistema parece responder com erros rápidos; por dentro, ele gasta capacidade com pedidos que já foram abandonados.

Cadeia A -> B. A espera no máximo 2 s. B executa 4 pedidos ao mesmo tempo, 300 ms cada, com fila de 20.

t=0       40 pedidos chegam a B: 4 executam, 20 esperam na fila, 16 recebem 503
t=300 ms  cada slot livre puxa o próximo item da fila
t=2000 ms A desiste dos pedidos pendentes e responde 504 ao cliente
          os itens que ficaram na fila continuam lá, com o prazo já vencido
          sem checagem na saída, B executaria todos eles e gastaria capacidade à toa

03

O timeout por salto que não cancela nada

O código abaixo é o padrão mais comum. Ele parece correto: A nunca espera mais de dois segundos, e os testes de A passam. Só que o timeout existe apenas do lado de A. Nenhum cabeçalho, nenhum prazo e nenhum sinal chega a B.

// servico-a: o timeout encerra a espera de A, nada chega a B
const resposta = await fetch('http://servico-b/pedidos', {
  method: 'POST',
  body: JSON.stringify(pedido),
  signal: AbortSignal.timeout(2000),
});

Com esse código, B trata cada pedido como se o cliente fosse esperar para sempre. Um pedido que ficou 1,8 s na fila e depois gasta 300 ms executando termina em 2,1 s, depois de A já ter desistido aos 2 s. B, mesmo assim, gasta os 300 ms.

04

O prazo como orçamento restante, não como horário absoluto

A solução é fazer o prazo atravessar a cadeia como orçamento: cada salto recebe quantos milissegundos ainda restam e desconta o que já gastou. Mandar um horário absoluto parece mais simples, mas exige relógios sincronizados entre máquinas. Se o relógio de B estiver atrasado em três segundos, ele aceita como válido um pedido que já venceu. Com o orçamento restante, cada medição é local ao processo, e o erro de latência de rede é pequeno o bastante para a margem absorver.

import http from 'node:http';

const LIMITE_CONCORRENCIA = 4; // quantas requisicoes executam ao mesmo tempo
const TAMANHO_MAXIMO_FILA = 20; // acima disso, rejeitar cedo com 503
const CABECALHO_PRAZO = 'x-prazo-restante-ms';

const fila = []; // itens aguardando execucao, cada um com prazo absoluto local
let emExecucao = 0;

function admitir(prazo, trabalho) {
  return new Promise((resolver, rejeitar) => {
    if (fila.length >= TAMANHO_MAXIMO_FILA) {
      rejeitar(Object.assign(new Error('fila cheia'), { status: 503 }));
      return;
    }
    fila.push({ prazo, trabalho, resolver, rejeitar });
    despachar();
  });
}

function despachar() {
  while (emExecucao < LIMITE_CONCORRENCIA && fila.length > 0) {
    const item = fila.shift();
    if (Date.now() >= item.prazo) {
      // o chamador ja desistiu: executar agora seria trabalho descartado
      item.rejeitar(Object.assign(new Error('prazo expirado na fila'), { status: 504 }));
      continue;
    }
    emExecucao++;
    item
      .trabalho()
      .then(item.resolver, item.rejeitar)
      .finally(() => {
        emExecucao--;
        despachar();
      });
  }
}

const processar = (pedido) =>
  new Promise((resolver) => setTimeout(() => resolver({ id: pedido.id, ok: true }), 300));

http
  .createServer(async (req, res) => {
    const prazo = Date.now() + Number(req.headers[CABECALHO_PRAZO] ?? 1000);
    let corpo = '';
    for await (const trecho of req) corpo += trecho;
    try {
      const resultado = await admitir(prazo, () => processar(JSON.parse(corpo || '{}')));
      res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(resultado));
    } catch (erro) {
      res.writeHead(erro.status ?? 500).end(erro.message);
    }
  })
  .listen(3001);
  • Concorrência máxima por instância, dimensionada pela capacidade real das dependências, como o pool do banco
  • Tamanho máximo da fila, com rejeição imediata quando ela está cheia
  • Checagem do prazo na saída da fila, antes de qualquer trabalho começar
  • Status 503 para fila cheia e 504 para prazo vencido, para que o chamador saiba o motivo

B aceita o pedido, mas só o põe em execução se o prazo ainda valer no momento de sair da fila. Um item que chega à frente depois de vencido recebe erro sem consumir nenhum recurso de execução.

05

A cadeia: cada salto gasta uma parte do orçamento

O serviço A lê o orçamento recebido, desconta o próprio trabalho local e reserva uma margem para responder antes de o cliente desistir. Se o que sobra não for positivo, A nem chama B. Esse é o ganho que mais preserva capacidade durante um incidente: um pedido que não tem mais como terminar a tempo não deve ocupar slot em nenhum ponto da cadeia.

import http from 'node:http';

const URL_B = 'http://servico-b/pedidos';
const MARGEM_MS = 20; // folga para A devolver a propria resposta antes do prazo

async function chamarB(pedido, restanteMs) {
  const resposta = await fetch(URL_B, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'x-prazo-restante-ms': String(restanteMs),
    },
    body: JSON.stringify(pedido),
    signal: AbortSignal.timeout(restanteMs),
  });
  if (!resposta.ok) throw new Error('B respondeu ' + resposta.status);
  return resposta.json();
}

http
  .createServer(async (req, res) => {
    const prazo = Date.now() + Number(req.headers['x-prazo-restante-ms'] ?? 2000);
    const restanteParaB = prazo - Date.now() - MARGEM_MS;
    if (restanteParaB <= 0) {
      res.writeHead(504).end('sem prazo para chamar B');
      return;
    }
    try {
      const dadosB = await chamarB({ id: req.url }, restanteParaB);
      res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(dadosB));
    } catch (erro) {
      res.writeHead(504).end(erro.name);
    }
  })
  .listen(3000);

A chamada usa AbortSignal.timeout com o restante, e não com o valor fixo de antes. O timeout local continua útil contra travamentos de rede, mas o prazo de negócio agora é um só, medido a partir do momento em que o pedido entrou na cadeia. Há um limite: a checagem na saída da fila não interrompe um trabalho que já começou. Para isso, o prazo precisa virar um AbortSignal repassado às chamadas de banco e HTTP feitas dentro do processamento.

06

Quanto custa o trabalho descartado e como provar que ele acabou

Em uma execução local do par acima, com B limitado a quatro execuções simultâneas, 300 ms por pedido e fila de 20, 40 pedidos chegaram de uma vez com orçamento de dois segundos. Parte das respostas voltou 200, dentro do prazo, e o restante voltou 504, seja porque B rejeitou com 503, seja porque o item venceu na fila. Nenhum item foi iniciado depois de vencer, porque a checagem acontece na saída da fila. Os 503 de B chegam a A, que os converte em 504; manter o status original é uma melhoria simples e preserva o sinal de rejeição rápida.

TesteComo montarCritério de aprovação
Carga acima da capacidadeEnviar pelo menos o triplo da capacidade de B durante dois minutosLatência limitada pelo prazo, sem crescimento contínuo da fila
Trabalho após o prazoRegistrar em B o instante de início de cada item e compará-lo com o prazo recebidoZero itens iniciados depois do prazo
Rejeição precoceLotar a fila de B e observar o status devolvido503 imediato, em vez de espera até o prazo
Orçamento repassadoInjetar 800 ms de atraso em A antes da chamada a BB recebe o restante já descontado e o cliente recebe a resposta dentro do prazo total

O último teste é o mais esquecido e costuma ser o que quebra a cadeia. O orçamento só funciona se cada salto lê o cabeçalho e o repassa. Um serviço intermediário que recria um timeout fixo apaga o prazo de ponta a ponta, e nenhum teste unitário percebe. Por isso o critério precisa ser medido do cliente até o último serviço.

FAQ

Perguntas frequentes

Por que não resolver reduzindo o timeout de cada salto?

Porque timeouts menores só mudam o momento em que a espera vira erro, não o momento em que o trabalho para. Em cadeias de três ou quatro saltos, cada um com timeout próprio, a soma ultrapassa o que o cliente aceita, e o trabalho abandonado continua ocupando a fila de quem atende. Reduzir os números ajuda, mas sem propagação o problema permanece. Com um orçamento único, o último salto sabe exatamente quanto tempo ainda existe e pode desistir antes de começar.

A fila limitada não rejeita pedidos que terminariam a tempo?

Pode rejeitar, e é isso que se quer quando a capacidade acabou. Uma fila sem limite não elimina a rejeição; ela só a transfere para o cliente, que recebe um timeout depois de esperar o prazo inteiro, e ainda deixa trabalho morto para trás. Rejeitar com 503 no momento da chegada permite que o cliente tente outra réplica ou degrade a funcionalidade com antecedência. O tamanho da fila deve sair da medição de quanto tempo um item pode esperar antes do prazo, não de um número redondo.

O prazo deve viajar como horário absoluto ou como tempo restante?

Tempo restante, em milissegundos, sempre que a cadeia cruza máquinas sem relógio sincronizado. O horário absoluto só é seguro com NTP bem configurado e monitorado, e mesmo assim cada salto precisa considerar a própria latência de chegada. O tempo restante é mais simples de raciocinar nos testes e não depende de nenhuma infraestrutura de tempo. O artigo sobre relógio dessincronizado entre serviços cobre os casos em que o horário absoluto parece inevitável.

O timeout encerra a espera; só o prazo propagado encerra o trabalho

Quando a espera de quem chama vira a fila de quem atende, reduzir timeouts não resolve. O que resolve é tratar o prazo como orçamento que desce pela cadeia, usar uma fila que descarta o que já venceu e rejeita cedo quando está cheia, e manter um teste de carga que prove que nenhum item começa depois do prazo. Sem isso, o incidente de hoje vira a carga da próxima onda de retentativas.