Voltar para o blog
Node.jsArquiteturaConfiabilidadeBackend

Circuit breaker no Node.js: resiliência em chamadas externas sem complicar

11 de junho de 20268 min de leiturapor Sávio Araújo

O problema em cascata

Imagine que sua API depende de um serviço de pagamento externo que ficou lento. Cada chamada demora 30 segundos antes de dar timeout. Sua aplicação começa a acumular requests aguardando. Os recursos se esgotam. Sua API fica lenta. Depois para.

Uma falha num serviço externo derrubou a aplicação inteira.

O circuit breaker resolve isso: ele detecta que o serviço está com problema e para de chamar por um tempo, retornando erro imediatamente em vez de esperar o timeout. O serviço externo tem espaço para se recuperar. Sua aplicação continua funcionando.

Os três estados

O circuit breaker tem três estados:

  • Closed: funcionamento normal, chamadas passam normalmente
  • Open: circuito aberto, chamadas falham imediatamente sem chamar o serviço
  • Half-open: testando recuperação, deixa passar algumas chamadas para ver se o serviço voltou
[Closed] ---(muitas falhas)---> [Open]
[Open] ---(timeout de recuperação)---> [Half-open]
[Half-open] ---(sucesso)---> [Closed]
[Half-open] ---(falha)---> [Open]

Estados do circuit breaker: Closed, Open e Half-Open

Usando a biblioteca opossum

Para Node.js, opossum é a biblioteca mais madura:

pnpm add opossum
pnpm add -D @types/opossum
import CircuitBreaker from "opossum";
 
async function callPaymentService(payload: PaymentPayload): Promise<PaymentResult> {
  const response = await fetch("https://payments.example.com/charge", {
    method: "POST",
    body: JSON.stringify(payload),
    signal: AbortSignal.timeout(5000),
  });
 
  if (!response.ok) throw new Error(`Payment failed: ${response.status}`);
  return response.json();
}
 
const breaker = new CircuitBreaker(callPaymentService, {
  timeout: 5000,          // timeout por chamada: 5s
  errorThresholdPercentage: 50, // abre se 50% das chamadas falharem
  resetTimeout: 30000,    // tenta fechar após 30s no estado Open
  volumeThreshold: 5,     // mínimo de chamadas antes de avaliar threshold
});
 
// Fallback quando o circuito está aberto
breaker.fallback((payload: PaymentPayload) => {
  throw new ServiceUnavailableException(
    "Serviço de pagamento temporariamente indisponível. Tente em alguns instantes."
  );
});
 
// Eventos para observabilidade
breaker.on("open", () => logger.warn("Circuit breaker aberto: payment service"));
breaker.on("close", () => logger.log("Circuit breaker fechado: payment service recuperado"));
breaker.on("halfOpen", () => logger.log("Circuit breaker half-open: testando payment service"));

Integrando com NestJS

Crie um serviço wrapper para o circuit breaker:

@Injectable()
export class PaymentService {
  private readonly breaker: CircuitBreaker;
  private readonly logger = new Logger(PaymentService.name);
 
  constructor(private readonly httpService: HttpService) {
    this.breaker = new CircuitBreaker(
      this.chargeInternal.bind(this),
      {
        timeout: 5000,
        errorThresholdPercentage: 50,
        resetTimeout: 30_000,
        volumeThreshold: 5,
      }
    );
 
    this.breaker.on("open", () =>
      this.logger.warn("Circuit breaker OPEN: serviço de pagamento com falhas")
    );
    this.breaker.on("close", () =>
      this.logger.log("Circuit breaker CLOSED: serviço de pagamento recuperado")
    );
  }
 
  async charge(payload: ChargeDto): Promise<ChargeResult> {
    try {
      return await this.breaker.fire(payload);
    } catch (error) {
      if (error.name === "OpenCircuitError") {
        throw new ServiceUnavailableException("Pagamentos temporariamente indisponíveis");
      }
      throw error;
    }
  }
 
  private async chargeInternal(payload: ChargeDto): Promise<ChargeResult> {
    const { data } = await firstValueFrom(
      this.httpService.post<ChargeResult>(
        `${process.env.PAYMENT_SERVICE_URL}/charge`,
        payload,
        { timeout: 5000 }
      )
    );
    return data;
  }
}

Circuit breaker com retry

Circuit breaker e retry trabalham juntos: retry trata falhas transitórias, circuit breaker trata serviços com problema persistente.

A ordem importa: retry dentro do circuit breaker, não o contrário.

const breaker = new CircuitBreaker(
  async (payload: PaymentPayload) => {
    // Retry interno: tenta 3 vezes antes de contar como falha para o breaker
    for (let attempt = 1; attempt <= 3; attempt++) {
      try {
        return await callPaymentService(payload);
      } catch (error) {
        if (attempt === 3) throw error;
        await new Promise((r) => setTimeout(r, 200 * attempt)); // 200ms, 400ms
      }
    }
  },
  {
    timeout: 15000, // timeout maior para cobrir os retries
    errorThresholdPercentage: 50,
    resetTimeout: 60_000,
  }
);

Múltiplos circuit breakers por serviço

Para sistemas com múltiplas dependências externas, mantenha um breaker por serviço:

@Injectable()
export class CircuitBreakerRegistry {
  private readonly breakers = new Map<string, CircuitBreaker>();
 
  get(serviceName: string, fn: (...args: unknown[]) => Promise<unknown>): CircuitBreaker {
    if (!this.breakers.has(serviceName)) {
      const breaker = new CircuitBreaker(fn, {
        timeout: 5000,
        errorThresholdPercentage: 50,
        resetTimeout: 30_000,
        volumeThreshold: 5,
        name: serviceName,
      });
 
      breaker.on("open", () =>
        this.metrics.increment(`circuit_breaker.open`, { service: serviceName })
      );
 
      this.breakers.set(serviceName, breaker);
    }
 
    return this.breakers.get(serviceName)!;
  }
 
  getStats(): Record<string, object> {
    const stats: Record<string, object> = {};
    for (const [name, breaker] of this.breakers) {
      stats[name] = breaker.stats;
    }
    return stats;
  }
}

Endpoint de health check

Exponha o estado dos circuit breakers para monitoramento:

@Get("health/circuit-breakers")
getCircuitBreakerHealth() {
  const stats = this.circuitBreakerRegistry.getStats();
 
  return {
    status: Object.values(stats).every((s: any) => !s.opened) ? "healthy" : "degraded",
    services: stats,
  };
}
{
  "status": "degraded",
  "services": {
    "payment-service": {
      "opened": true,
      "failures": 45,
      "successes": 12,
      "latencyMean": 4823
    },
    "notification-service": {
      "opened": false,
      "failures": 2,
      "successes": 198,
      "latencyMean": 145
    }
  }
}

Quando não usar circuit breaker

Circuit breakers adicionam complexidade. Nem toda chamada externa precisa:

  • Banco de dados próprio: use pool de conexões com timeouts bem configurados
  • Cache Redis: normalmente resiliente e de baixa latência
  • Operações que não têm fallback: se você precisa obrigatoriamente de uma resposta, circuit breaker só muda quando o erro aparece, não o que fazer com ele

Use circuit breaker principalmente para serviços de terceiros onde você pode ter um fallback razoável ou onde a latência alta do timeout causaria dano maior que o erro imediato.

Conclusão

Circuit breaker é um dos patterns mais importantes para sistemas que dependem de serviços externos. Ele muda o comportamento de "falha em cascata silenciosa" para "falha rápida e localizada".

Com opossum no Node.js, a implementação é simples. O trabalho real está em definir os thresholds corretos para o seu padrão de tráfego e ter fallbacks que façam sentido para o usuário.

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech