Blog

Circuit breaker que nunca abre: proteger o serviço de uma dependência lenta, não só da que caiu

Um circuit breaker clássico conta erros. Quando a dependência cai, as chamadas falham rápido, o contador estoura, o circuito abre e o serviço se protege. Só que a dependência que mais derruba serviço em produção não cai: ela fica lenta. Responde 200 em oito segundos em vez de oitenta milissegundos, e o breaker configurado por taxa de erro nunca vê um erro para contar. Enquanto isso, cada chamada ocupa uma conexão, um slot do pool e uma promessa pendente, até o chamador esgotar os próprios recursos e morrer saudável por dentro. Este artigo mostra por que o breaker por erro é cego para a lentidão, como tratar latência como falha com uma janela deslizante e taxa de chamadas lentas, como um bulkhead impede que a lentidão ocupe tudo, como sondar no estado meio-aberto sem soltar a onda represada e como provar em teste que o circuito abre com uma dependência que só demora.

2026-10-13 / Arquitetura / 13 min

01

Uma dependência que caiu custa menos do que uma que ficou lenta

Quando uma dependência cai de verdade, o erro chega rápido: conexão recusada, DNS que não resolve, 503 imediato. O chamador gasta alguns milissegundos por tentativa, libera a conexão e segue para o fallback. Quando a dependência fica lenta, nada disso acontece. A conexão é aceita, a requisição entra, e o chamador fica esperando, com o socket aberto, o slot do pool ocupado e a promessa pendente na memória. Durante toda a espera não existe erro. Existe só tempo passando. E tempo é o recurso que o breaker por erro não mede.

CenárioSinal que o chamador vêTempo preso por chamadaO que o breaker por erro faz
Dependência caiuECONNREFUSED ou 503 em poucos milissegundosMilissegundosConta o erro e abre em segundos
Dependência lenta com timeout200 depois de 8 s, ou timeout aos 10 sAté o timeoutSó vê erro quando o timeout estoura; abre tarde ou não abre
Dependência lenta sem timeout200 quando ela quiserIndefinidoNunca abre; o chamador esgota o pool e para de atender

As duas últimas linhas são as que aparecem em pós-incidente acompanhadas da frase "o circuit breaker estava configurado". Ele estava. Só que a configuração media a dimensão errada: contava quantas chamadas falharam, quando o dano vinha de quanto tempo cada chamada ficou presa.

02

Por que o breaker contado por erro nunca abre

Um serviço de checkout chama o catálogo de preços com um pool de 50 conexões. Em condição normal o catálogo responde em 80 ms, o que permite cerca de 600 chamadas por segundo com o pool inteiro ocupado. Um deploy ruim no catálogo faz o tempo de resposta subir para 8 s, mantendo o 200. Pela lei de Little, com as mesmas 50 conexões o checkout passa a conseguir no máximo 6 chamadas por segundo. O tráfego não caiu, então a fila do pool cresce, o p99 do checkout vai a nove segundos e o balanceador acaba tirando a instância de rotação quando o health check também estoura. Taxa de erro durante o episódio inteiro: zero.

Checkout chama o catálogo de preços com pool de 50 conexões. O catálogo passa de 80 ms para 8 s, ainda respondendo 200.

t=0       o catálogo começa a responder em 8 s; taxa de erro: 0%
t=4 s     50 chamadas em voo, todas esperando; a 51ª entra na fila do pool
t=8 s     as 50 primeiras voltam com 200; o breaker por erro não tem nada a registrar
t=30 s    p99 do checkout em 9,4 s; CPU em 12%; event loop livre; memória subindo
t=45 s    o balanceador tira o checkout de rotação: o health check também estourou
          em nenhum momento o circuito abriu, porque não houve erro para contar

O detalhe que costuma passar despercebido: um timeout no cliente converte lentidão em erro, mas só depois de a chamada ter ocupado o recurso pelo prazo inteiro. Com timeout de 10 s e latência de 8 s, nenhuma chamada estoura e o breaker continua cego. Com timeout de 2 s, cada chamada ainda prende a conexão por 2 s antes de virar um erro contável, e o breaker precisa de dezenas delas para abrir. O timeout é necessário como teto, mas não é o sinal certo para decidir.

03

Latência é falha: janela deslizante com taxa de chamadas lentas

A correção é contar duas coisas em vez de uma. Cada chamada registra se falhou e se foi lenta, comparando a duração com um limite definido a partir da latência saudável da dependência. O circuito abre quando a taxa de erro ou a taxa de chamadas lentas passa do limiar dentro de uma janela deslizante com as últimas N chamadas, desde que a janela tenha um mínimo de amostras para não decidir com base em duas requisições. É o mesmo modelo que bibliotecas maduras como o Resilience4j adotam com o nome de slow call rate, e cabe em poucas dezenas de linhas quando o objetivo é entender o que está acontecendo.

// circuit-breaker.js
export class CircuitoAbertoError extends Error {
  constructor(nome) {
    super(`circuito ${nome} aberto`);
    this.codigo = 'CIRCUITO_ABERTO';
  }
}

export class CircuitBreaker {
  constructor({
    nome,
    janela = 20, // ultimas N chamadas consideradas na decisao
    minimoChamadas = 10, // nao decidir com amostra menor que isso
    limiteLentaMs = 500, // a partir desta duracao a chamada conta como lenta
    taxaLentaAbrir = 0.5, // fracao de chamadas lentas que abre o circuito
    taxaErroAbrir = 0.5, // fracao de erros que abre o circuito
    timeoutMs = 2000, // teto de espera por uma unica chamada
    tempoAbertoMs = 10_000, // quanto tempo fica aberto antes de sondar
    sondasMeioAberto = 3, // chamadas reais permitidas no estado meio-aberto
    agora = () => Date.now(), // relogio injetavel para os testes
  }) {
    Object.assign(this, {
      nome, janela, minimoChamadas, limiteLentaMs, taxaLentaAbrir,
      taxaErroAbrir, timeoutMs, tempoAbertoMs, sondasMeioAberto, agora,
    });
    this.estado = 'fechado';
    this.amostras = []; // { erro, lenta }
    this.abertoAte = 0;
    this.sondasRestantes = 0;
  }

  async executar(fn) {
    this.admitir();
    const inicio = this.agora();
    const controlador = new AbortController();
    const temporizador = setTimeout(() => controlador.abort(), this.timeoutMs);
    try {
      const resultado = await fn(controlador.signal);
      this.registrar({ erro: false, duracaoMs: this.agora() - inicio });
      return resultado;
    } catch (erro) {
      this.registrar({ erro: true, duracaoMs: this.agora() - inicio });
      throw erro;
    } finally {
      clearTimeout(temporizador);
    }
  }

  admitir() {
    if (this.estado === 'aberto') {
      if (this.agora() < this.abertoAte) throw new CircuitoAbertoError(this.nome);
      this.estado = 'meio-aberto';
      this.sondasRestantes = this.sondasMeioAberto;
      this.amostras = [];
    }
    if (this.estado === 'meio-aberto') {
      if (this.sondasRestantes === 0) throw new CircuitoAbertoError(this.nome);
      this.sondasRestantes -= 1;
    }
  }

  registrar({ erro, duracaoMs }) {
    if (this.estado === 'aberto') return; // chamada que ja estava em voo: nao decide nada
    const lenta = duracaoMs >= this.limiteLentaMs;
    this.amostras.push({ erro, lenta });
    if (this.amostras.length > this.janela) this.amostras.shift();

    if (this.estado === 'meio-aberto') {
      if (erro || lenta) return this.abrir();
      if (this.amostras.length >= this.sondasMeioAberto) this.fechar();
      return;
    }
    if (this.amostras.length < this.minimoChamadas) return;
    const taxa = (chave) =>
      this.amostras.filter((amostra) => amostra[chave]).length / this.amostras.length;
    if (taxa('erro') >= this.taxaErroAbrir || taxa('lenta') >= this.taxaLentaAbrir) this.abrir();
  }

  abrir() {
    this.estado = 'aberto';
    this.abertoAte = this.agora() + this.tempoAbertoMs;
    this.amostras = [];
  }

  fechar() {
    this.estado = 'fechado';
    this.amostras = [];
  }
}

Três decisões no código merecem atenção. A primeira é que o timeout vive dentro do breaker e aborta a chamada pelo AbortSignal, de forma que a espera tem teto mesmo quando a dependência nunca responde. A segunda é que a duração é medida para erros também: um timeout de dois segundos conta como erro e como chamada lenta ao mesmo tempo. A terceira é que, no estado aberto, resultados de chamadas que ainda estavam em voo são ignorados, para que uma chamada atrasada não estenda o tempo de abertura nem feche o circuito sozinha.

ParâmetroComo definirErro comum
limiteLentaMsDuas a três vezes o p99 da dependência saudável, medido em produçãoCopiar o valor do timeout; nessa altura a chamada já ocupou tudo
timeoutMsO máximo que o chamador aceita esperar por uma única respostaDeixar sem timeout e confiar só no breaker
janela e minimoChamadasGrandes o bastante para absorver um pico isolado de latênciaJanela de 5 chamadas que abre e fecha a cada rajada
taxaLentaAbrir50% é um ponto de partida; dependências críticas podem usar menosExigir 100% e nunca abrir com uma dependência meio lenta
tempoAbertoMsTempo suficiente para a dependência se recuperar, com jitter entre instânciasValor idêntico em todas as réplicas, que sondam no mesmo instante

04

Bulkhead: limitar o que está em voo para que a lentidão não ocupe tudo

O breaker com taxa de chamadas lentas resolve a cegueira, mas ainda precisa de amostras para decidir. Entre a dependência ficar lenta e a janela encher, as chamadas em voo continuam ocupando recursos, e com um pico de tráfego elas podem esgotar o pool antes que o circuito abra. O bulkhead fecha essa brecha: ele limita quantas chamadas àquela dependência podem estar em andamento ao mesmo tempo e rejeita na hora o que passar disso. Diferente do breaker, ele não precisa de histórico; a saturação atual é a evidência.

// bulkhead.js
export class Bulkhead {
  constructor({ nome, maxEmVoo = 20 }) {
    this.nome = nome;
    this.maxEmVoo = maxEmVoo;
    this.emVoo = 0;
  }

  async executar(fn) {
    if (this.emVoo >= this.maxEmVoo) {
      throw Object.assign(new Error(`bulkhead ${this.nome} cheio`), { codigo: 'BULKHEAD_CHEIO' });
    }
    this.emVoo += 1;
    try {
      return await fn();
    } finally {
      this.emVoo -= 1;
    }
  }
}

// cliente-catalogo.js
import { CircuitBreaker } from './circuit-breaker.js';
import { Bulkhead } from './bulkhead.js';
import { precoEmCache } from './cache-precos.js';

const breaker = new CircuitBreaker({ nome: 'catalogo', limiteLentaMs: 500, timeoutMs: 2000 });
const bulkhead = new Bulkhead({ nome: 'catalogo', maxEmVoo: 20 }); // menor que o pool de conexoes

export async function buscarPreco(sku) {
  try {
    return await bulkhead.executar(() =>
      breaker.executar(async (signal) => {
        const resposta = await fetch(`http://catalogo/precos/${sku}`, { signal });
        if (!resposta.ok) throw new Error(`catalogo respondeu ${resposta.status}`);
        return resposta.json();
      }),
    );
  } catch (erro) {
    if (erro.codigo === 'CIRCUITO_ABERTO' || erro.codigo === 'BULKHEAD_CHEIO') {
      return precoEmCache(sku); // degradacao explicita: responde sem esperar
    }
    throw erro;
  }
}

A ordem da composição importa. O bulkhead fica por fora e o breaker por dentro: uma rejeição do breaker dura microssegundos e quase não ocupa o bulkhead, enquanto uma rejeição do bulkhead nunca chega ao breaker e por isso não contamina a janela com um erro que não diz nada sobre a dependência. O limite de chamadas em voo deve ser menor do que o pool de conexões do chamador, para que uma única dependência lenta nunca consiga ocupar o pool inteiro e levar junto as chamadas a dependências saudáveis.

05

Meio-aberto: sondar sem soltar a onda represada

Quando o tempo de abertura termina, o circuito não deve simplesmente fechar. Ele passa para meio-aberto e deixa um número pequeno de chamadas reais servirem de sonda. Se as sondas voltam rápido e sem erro, fecha. Se uma delas falha ou demora, abre de novo e reinicia o relógio. O ponto crítico é que, durante a sondagem, todas as outras chamadas continuam recebendo a rejeição rápida. Sem esse limite, o instante em que o circuito reabre descarrega sobre a dependência todo o tráfego represado, e uma dependência que estava quase recuperada volta a ficar lenta.

  • Sondas com o mesmo critério de lentidão: uma sonda que responde 200 em três segundos é uma sonda que falhou.
  • Jitter no tempo de abertura: com dez réplicas usando exatamente 10 s, todas sondam no mesmo segundo e as sondas somadas viram um pico.
  • Fallback explícito enquanto o circuito está aberto: devolver preço em cache, esconder a seção ou enfileirar para depois, mas nunca esperar.
  • Registro de cada transição de estado com o motivo, porque a pergunta do pós-incidente é sempre por que abriu às 14:07.

06

O que medir para saber que o circuito está fazendo o trabalho dele

Um breaker que nunca abre e um breaker que nunca precisou abrir têm o mesmo gráfico até a lentidão chegar. Para diferenciar os dois antes do incidente, as métricas precisam expor o que o breaker está vendo, não só o estado em que ele está.

MétricaGranularidadeAlerta sugerido
Taxa de chamadas lentas na janelaPor dependência e operaçãoAcima de metade do limiar de abertura por mais de um minuto
Chamadas em voo e rejeições do bulkheadPor dependênciaQualquer rejeição fora de horário de pico
Transições de estado do circuitoPor dependência e instânciaMais de três aberturas em dez minutos indica oscilação
Tempo acumulado em estado abertoPor dependênciaAberto por mais tempo do que o SLO da funcionalidade permite
Latência da dependência em p50 e p99Por operaçãop99 acima do limite de chamada lenta configurado

A última linha é a que mantém a configuração honesta. Se o p99 saudável da dependência sobe ao longo dos meses e ninguém revisa o limite, o breaker passa a abrir em dias normais, alguém aumenta o limiar para fazer o alerta parar, e o serviço volta a ficar cego.

07

Como provar que ele abre com uma dependência que só demora

O teste que importa não é o da dependência que cai; esse quase sempre passa. É o da dependência que responde sucesso devagar. Com o relógio injetado no breaker, dá para simular latência sem esperar de verdade: cada chamada falsa avança o relógio em 800 ms e devolve sucesso. Depois de dez chamadas, a janela está cheia de chamadas lentas sem nenhum erro, e a décima primeira precisa ser rejeitada sem executar.

// circuit-breaker.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { CircuitBreaker, CircuitoAbertoError } from './circuit-breaker.js';

const criarBreaker = (relogio) =>
  new CircuitBreaker({
    nome: 'catalogo',
    janela: 10,
    minimoChamadas: 10,
    limiteLentaMs: 500,
    taxaLentaAbrir: 0.5,
    timeoutMs: 2000,
    tempoAbertoMs: 10_000,
    sondasMeioAberto: 3,
    agora: () => relogio.valor,
  });

const lentaComSucesso = (relogio) => async () => {
  relogio.valor += 800; // a dependencia responde 200, mas demora 800 ms
  return { ok: true };
};

test('abre com dependencia que responde sucesso em 800 ms', async () => {
  const relogio = { valor: 0 };
  const breaker = criarBreaker(relogio);

  for (let i = 0; i < 10; i += 1) await breaker.executar(lentaComSucesso(relogio));

  assert.equal(breaker.estado, 'aberto');
  await assert.rejects(breaker.executar(lentaComSucesso(relogio)), CircuitoAbertoError);
});

test('fecha de novo quando as sondas voltam rapido', async () => {
  const relogio = { valor: 0 };
  const breaker = criarBreaker(relogio);
  for (let i = 0; i < 10; i += 1) await breaker.executar(lentaComSucesso(relogio));
  relogio.valor += 10_000; // tempo aberto esgotado: as proximas chamadas sao sondas

  for (let i = 0; i < 3; i += 1) await breaker.executar(async () => ({ ok: true }));

  assert.equal(breaker.estado, 'fechado');
});

test('sonda lenta reabre o circuito mesmo devolvendo sucesso', async () => {
  const relogio = { valor: 0 };
  const breaker = criarBreaker(relogio);
  for (let i = 0; i < 10; i += 1) await breaker.executar(lentaComSucesso(relogio));
  relogio.valor += 10_000;

  await breaker.executar(async () => {
    relogio.valor += 3000;
    return { ok: true };
  });

  assert.equal(breaker.estado, 'aberto');
});

Vale completar com dois cenários: uma chamada lenta isolada no meio de chamadas rápidas não deve abrir o circuito, e um timeout real, com a dependência que nunca responde, deve ser abortado pelo sinal e contado como erro. Esses testes rodam em milissegundos e são a única garantia de que a configuração mede o que o incidente vai exercitar.

FAQ

Perguntas frequentes

O timeout no cliente não resolve o mesmo problema?

Resolve parte. O timeout garante que nenhuma chamada espera para sempre, mas cada chamada ainda ocupa a conexão pelo prazo inteiro antes de virar erro, e o breaker por erro precisa de muitas delas para abrir. Com latência pouco abaixo do timeout, nenhuma estoura e o circuito permanece fechado enquanto o pool esgota. O timeout é o teto; a taxa de chamadas lentas é o sinal que permite abrir antes de o teto ser atingido.

Qual limite de chamada lenta usar quando a dependência tem latência variável?

Partir do p99 medido em produção em período saudável, multiplicado por dois ou três, e revisar junto com o SLO da funcionalidade. Se a dependência tem operações com latências muito diferentes, como uma busca simples e um relatório pesado, usar um breaker por operação, não um por host. Um único limite para tudo ou abre em dias normais por causa da operação pesada, ou nunca abre por causa da leve.

O bulkhead não torna o breaker desnecessário?

Não, eles cobrem falhas diferentes. O bulkhead reage à saturação atual e impede que uma dependência lenta ocupe o pool inteiro, mas continua mandando chamadas para ela até o limite, cada uma esperando o tempo todo. O breaker usa o histórico para parar de chamar quando a evidência mostra que não vale a pena, e devolve a rejeição em microssegundos. Juntos, um protege os recursos do chamador e o outro protege o tempo do usuário.

Um breaker que só conta erros protege contra a falha que menos acontece

A dependência que derruba serviço em produção costuma estar viva e lenta, não morta. Para que o circuito abra nesse caso, a latência precisa contar como falha, a janela precisa ter amostras suficientes para decidir, um bulkhead precisa limitar o que está em voo enquanto a janela enche, e o estado meio-aberto precisa sondar sem soltar a onda represada. Sem o teste da dependência lenta, a configuração continua sendo uma suposição.