'use strict'; /** * circuit-breaker.js — Circuit breaker for API resilience. * * States: * CLOSED — Normal operation. Failures tracked. * OPEN — Too many failures. Calls rejected immediately. * HALF_OPEN — Cooldown expired. One probe call allowed. * * Transition rules: * CLOSED -> OPEN: failures >= threshold * OPEN -> HALF_OPEN: cooldown expired * HALF_OPEN -> CLOSED: probe succeeds * HALF_OPEN -> OPEN: probe fails */ const STATE = { CLOSED: 'closed', OPEN: 'open', HALF_OPEN: 'half_open', }; class CircuitBreaker { /** * @param {object} opts * @param {number} opts.threshold - Consecutive failures to open (default 5) * @param {number} opts.cooldownMs - Milliseconds before half-open probe (default 30000) * @param {Function} [opts.onStateChange] - Called with (newState, oldState) * @param {object} [opts.logger] - Optional logger */ constructor(opts = {}) { this.threshold = opts.threshold || 5; this.cooldownMs = opts.cooldownMs || 30000; this.onStateChange = opts.onStateChange || null; this.logger = opts.logger || null; this.state = STATE.CLOSED; this.failures = 0; this.openedAt = null; this.lastError = null; } /** * Execute a function through the circuit breaker. * Throws CircuitOpenError if the circuit is open. * @param {Function} fn - Async function to execute * @returns {Promise<*>} */ async execute(fn) { if (this.state === STATE.OPEN) { const elapsed = Date.now() - this.openedAt; if (elapsed >= this.cooldownMs) { this._transition(STATE.HALF_OPEN); } else { throw new CircuitOpenError( `Circuit open (${Math.ceil((this.cooldownMs - elapsed) / 1000)}s remaining)`, this.lastError, ); } } try { const result = await fn(); this._onSuccess(); return result; } catch (err) { this._onFailure(err); throw err; } } _onSuccess() { if (this.state === STATE.HALF_OPEN) { this._transition(STATE.CLOSED); } this.failures = 0; this.lastError = null; } _onFailure(err) { this.lastError = err; this.failures++; if (this.state === STATE.HALF_OPEN) { // Probe failed — reopen this.openedAt = Date.now(); this._transition(STATE.OPEN); } else if (this.state === STATE.CLOSED && this.failures >= this.threshold) { this.openedAt = Date.now(); this._transition(STATE.OPEN); } } _transition(newState) { const oldState = this.state; this.state = newState; if (newState === STATE.CLOSED) { this.failures = 0; this.openedAt = null; } if (this.logger) { this.logger.warn({ from: oldState, to: newState }, 'Circuit breaker state change'); } if (this.onStateChange) { this.onStateChange(newState, oldState); } } getState() { return this.state; } getMetrics() { return { state: this.state, failures: this.failures, threshold: this.threshold, openedAt: this.openedAt, lastError: this.lastError ? this.lastError.message : null, }; } reset() { this.state = STATE.CLOSED; this.failures = 0; this.openedAt = null; this.lastError = null; } } class CircuitOpenError extends Error { constructor(message, cause) { super(message); this.name = 'CircuitOpenError'; this.cause = cause; } } module.exports = { CircuitBreaker, CircuitOpenError, STATE };