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âmetro | Como definir | Erro comum |
|---|
| limiteLentaMs | Duas a três vezes o p99 da dependência saudável, medido em produção | Copiar o valor do timeout; nessa altura a chamada já ocupou tudo |
| timeoutMs | O máximo que o chamador aceita esperar por uma única resposta | Deixar sem timeout e confiar só no breaker |
| janela e minimoChamadas | Grandes o bastante para absorver um pico isolado de latência | Janela de 5 chamadas que abre e fecha a cada rajada |
| taxaLentaAbrir | 50% é um ponto de partida; dependências críticas podem usar menos | Exigir 100% e nunca abrir com uma dependência meio lenta |
| tempoAbertoMs | Tempo suficiente para a dependência se recuperar, com jitter entre instâncias | Valor idêntico em todas as réplicas, que sondam no mesmo instante |