Blog

Timeouts e circuit breaker: o mínimo de resiliência.

Existe uma intuição errada e muito difundida: a falha perigosa é o serviço que cai.

Serviço caído devolve erro na hora. Você trata, mostra mensagem, segue. É o caso fácil.

O perigoso é o serviço lento. Ele não devolve erro: ele segura a sua requisição. E cada requisição segurada é uma conexão do pool, uma thread, um pedaço de memória, tudo parado esperando.

Com tráfego normal, uma dependência que passou de 200ms pra 8 segundos consome todos os seus recursos em minutos. A sua aplicação cai inteira, incluindo as partes que não usam aquela dependência.

Timeout: a configuração mais negligenciada

Quase toda biblioteca HTTP vem sem timeout por padrão, ou com um padrão de vários minutos. Isso significa que o seu sistema está disposto a esperar pra sempre por qualquer coisa.

const res = await fetch(url, {
  signal: AbortSignal.timeout(3000),
});

Precisa existir timeout em três camadas, e elas frequentemente são confundidas:

Conexão, que é quanto tempo pra estabelecer o socket. Curto, por volta de um segundo.

Leitura, que é quanto tempo esperando resposta. Depende da operação.

E o total, que é o limite de tudo somado, incluindo retentativas. É o que protege de verdade.

Sem o total, três tentativas de 5 segundos com backoff viram 20 segundos de espera, e o usuário do outro lado já foi embora.

Escolher o número

Chutar valor redondo é como se cria o problema do gateway que responde em 6 segundos com timeout de 5.

O método: meça o percentil 99 da chamada em condição normal e use duas a três vezes isso.

Se o p99 é 400ms, timeout de 1 segundo. Se é 3 segundos, timeout de 8. Fora dessa faixa, ou você está cortando requisição legítima ou está esperando demais.

E timeout de operação síncrona, com usuário esperando, precisa ser menor que a paciência dele. Não adianta esperar 30 segundos por uma resposta que ninguém vai ver.

Circuit breaker: parar de tentar

Timeout limita o dano de cada chamada. Quando a dependência está fora de verdade, você continua gastando 3 segundos por requisição pra descobrir a mesma coisa toda vez.

O disjuntor conta as falhas numa janela. Passando do limite, abre: as chamadas seguintes falham imediatamente, sem tocar a rede. Depois de um tempo, deixa passar uma pra testar. Se der certo, fecha.

class CircuitBreaker {
  private failures = 0;
  private openedAt?: number;

  constructor(
    private readonly threshold = 5,
    private readonly cooldownMs = 30_000,
  ) {}

  async call<T>(fn: () => Promise<T>): Promise<T> {
    if (this.openedAt && Date.now() - this.openedAt < this.cooldownMs) {
      throw new CircuitOpenError();          // falha em microssegundos
    }

    try {
      const result = await fn();
      this.failures = 0;
      this.openedAt = undefined;
      return result;
    } catch (err) {
      if (++this.failures >= this.threshold) this.openedAt = Date.now();
      throw err;
    }
  }
}

O ganho principal não é proteger o outro serviço. É proteger o seu: sem disjuntor, todas as suas threads ficam presas esperando timeout de algo que já se sabe que está morto.

Com ele, a funcionalidade que dependia daquilo degrada e o resto da aplicação continua de pé. Essa é a diferença entre “o relatório está indisponível” e “o site caiu”.

Anteparo: isolar por dependência

Se todas as chamadas externas compartilham o mesmo pool de conexões, uma dependência lenta consome o pool inteiro e derruba as chamadas pras outras.

A correção é dar a cada dependência a sua fatia:

const gatewayPool = new Semaphore(10);
const emailPool = new Semaphore(5);
const analyticsPool = new Semaphore(3);

Se o serviço de analytics travar, ele segura três slots. Os outros continuam. Isso tem nome, bulkhead, e vem da divisão em compartimentos estanques de navio: um compartimento alagado não afunda o barco.

O que fazer quando o disjuntor abre

Essa é a parte de produto, e é a que decide se a resiliência serve pra alguma coisa.

Para cada dependência, defina antes o que acontece quando ela não responde.

Serviço de recomendação fora: mostre os mais vendidos. Degradação invisível.

Serviço de e-mail fora: enfileire e mande depois. O usuário não precisa saber.

Gateway de pagamento fora: aí não tem plano B, e a mensagem precisa ser honesta e específica, com orientação de tentar mais tarde.

O que não pode acontecer é a página inteira quebrar porque um bloco lateral não carregou.

O teste que quase ninguém faz

Configuração de resiliência que nunca foi exercitada é ficção.

Em homologação, derrube uma dependência e observe. Depois faça pior: deixe ela lenta, respondendo em 30 segundos. O segundo cenário é o que quebra sistema, e é o que ninguém testa porque é mais difícil de simular que desligar.

Um proxy que injeta latência resolve, e uma tarde fazendo isso costuma revelar dois ou três lugares sem timeout que ninguém sabia que existiam.

A ordem que eu seguiria

Timeout em toda chamada externa, incluindo banco e cache. É a única não negociável, e é a que falta com mais frequência.

Depois statement_timeout no banco, que impede uma query ruim de segurar conexão.

Depois disjuntor nas dependências externas críticas.

E por último anteparo, que só importa quando existem várias dependências disputando o mesmo recurso.

Sobre a parte de retentativa, que anda junto com isso, escrevi em retry, backoff e dead letter queue.

← Voltar pro blog