233 lines
6.9 KiB
JavaScript
233 lines
6.9 KiB
JavaScript
/**
|
|
* Circuit Breaker Pattern
|
|
*
|
|
* Prevents cascading failures by breaking the circuit after failures.
|
|
*
|
|
* @module v3/shared/resilience/circuit-breaker
|
|
*/
|
|
import { EventEmitter } from 'events';
|
|
/**
|
|
* Circuit breaker states
|
|
*/
|
|
export var CircuitBreakerState;
|
|
(function (CircuitBreakerState) {
|
|
/** Circuit is closed, requests flow normally */
|
|
CircuitBreakerState["CLOSED"] = "CLOSED";
|
|
/** Circuit is open, requests are rejected immediately */
|
|
CircuitBreakerState["OPEN"] = "OPEN";
|
|
/** Circuit is testing if service recovered */
|
|
CircuitBreakerState["HALF_OPEN"] = "HALF_OPEN";
|
|
})(CircuitBreakerState || (CircuitBreakerState = {}));
|
|
/**
|
|
* Default options
|
|
*/
|
|
const DEFAULT_OPTIONS = {
|
|
failureThreshold: 5,
|
|
successThreshold: 3,
|
|
timeout: 30000,
|
|
rollingWindow: 60000,
|
|
volumeThreshold: 10,
|
|
};
|
|
/**
|
|
* Circuit Breaker
|
|
*
|
|
* Implements the circuit breaker pattern to prevent cascading failures.
|
|
*
|
|
* @example
|
|
* const breaker = new CircuitBreaker({
|
|
* name: 'external-api',
|
|
* failureThreshold: 5,
|
|
* timeout: 30000,
|
|
* });
|
|
*
|
|
* try {
|
|
* const result = await breaker.execute(() => fetchExternalAPI());
|
|
* } catch (error) {
|
|
* if (error.message === 'Circuit is open') {
|
|
* // Handle circuit open case
|
|
* }
|
|
* }
|
|
*/
|
|
export class CircuitBreaker extends EventEmitter {
|
|
options;
|
|
state = CircuitBreakerState.CLOSED;
|
|
requests = [];
|
|
halfOpenSuccesses = 0;
|
|
openedAt = null;
|
|
lastFailure = null;
|
|
lastSuccess = null;
|
|
rejectedCount = 0;
|
|
timeoutId;
|
|
constructor(options) {
|
|
super();
|
|
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
}
|
|
/**
|
|
* Execute a function through the circuit breaker
|
|
*/
|
|
async execute(fn) {
|
|
// Clean up old requests
|
|
this.cleanOldRequests();
|
|
// Check if circuit should be tested
|
|
this.checkState();
|
|
// If open, reject immediately or use fallback
|
|
if (this.state === CircuitBreakerState.OPEN) {
|
|
this.rejectedCount++;
|
|
const error = new Error(`Circuit breaker '${this.options.name}' is open`);
|
|
if (this.options.fallback) {
|
|
return this.options.fallback(error);
|
|
}
|
|
throw error;
|
|
}
|
|
try {
|
|
const result = await fn();
|
|
this.onSuccess();
|
|
return result;
|
|
}
|
|
catch (error) {
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
// Check if this should be counted as failure
|
|
const isFailure = this.options.isFailure?.(err) ?? true;
|
|
if (isFailure) {
|
|
this.onFailure(err);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
/**
|
|
* Get current state
|
|
*/
|
|
getState() {
|
|
this.checkState();
|
|
return this.state;
|
|
}
|
|
/**
|
|
* Get statistics
|
|
*/
|
|
getStats() {
|
|
this.cleanOldRequests();
|
|
return {
|
|
state: this.state,
|
|
failures: this.requests.filter((r) => !r.success).length,
|
|
successes: this.requests.filter((r) => r.success).length,
|
|
totalRequests: this.requests.length,
|
|
rejectedRequests: this.rejectedCount,
|
|
lastFailure: this.lastFailure,
|
|
lastSuccess: this.lastSuccess,
|
|
openSince: this.openedAt,
|
|
};
|
|
}
|
|
/**
|
|
* Force reset the circuit breaker
|
|
*/
|
|
reset() {
|
|
const previousState = this.state;
|
|
this.state = CircuitBreakerState.CLOSED;
|
|
this.requests = [];
|
|
this.halfOpenSuccesses = 0;
|
|
this.openedAt = null;
|
|
if (this.timeoutId) {
|
|
clearTimeout(this.timeoutId);
|
|
this.timeoutId = undefined;
|
|
}
|
|
if (previousState !== this.state) {
|
|
this.notifyStateChange(previousState, this.state);
|
|
}
|
|
}
|
|
/**
|
|
* Handle successful request
|
|
*/
|
|
onSuccess() {
|
|
this.lastSuccess = new Date();
|
|
this.requests.push({ timestamp: Date.now(), success: true });
|
|
if (this.state === CircuitBreakerState.HALF_OPEN) {
|
|
this.halfOpenSuccesses++;
|
|
if (this.halfOpenSuccesses >= this.options.successThreshold) {
|
|
this.transitionTo(CircuitBreakerState.CLOSED);
|
|
this.halfOpenSuccesses = 0;
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Handle failed request
|
|
*/
|
|
onFailure(error) {
|
|
this.lastFailure = new Date();
|
|
this.requests.push({ timestamp: Date.now(), success: false });
|
|
if (this.state === CircuitBreakerState.HALF_OPEN) {
|
|
// Failed during half-open, go back to open
|
|
this.transitionTo(CircuitBreakerState.OPEN);
|
|
this.halfOpenSuccesses = 0;
|
|
return;
|
|
}
|
|
// Check if we should open the circuit
|
|
const failures = this.requests.filter((r) => !r.success).length;
|
|
const totalRequests = this.requests.length;
|
|
if (totalRequests >= this.options.volumeThreshold &&
|
|
failures >= this.options.failureThreshold) {
|
|
this.transitionTo(CircuitBreakerState.OPEN);
|
|
}
|
|
}
|
|
/**
|
|
* Check if state should change based on timeout
|
|
*/
|
|
checkState() {
|
|
if (this.state === CircuitBreakerState.OPEN && this.openedAt) {
|
|
const elapsed = Date.now() - this.openedAt.getTime();
|
|
if (elapsed >= this.options.timeout) {
|
|
this.transitionTo(CircuitBreakerState.HALF_OPEN);
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Transition to new state
|
|
*/
|
|
transitionTo(newState) {
|
|
const previousState = this.state;
|
|
if (previousState === newState) {
|
|
return;
|
|
}
|
|
this.state = newState;
|
|
if (newState === CircuitBreakerState.OPEN) {
|
|
this.openedAt = new Date();
|
|
this.scheduleHalfOpen();
|
|
}
|
|
else if (newState === CircuitBreakerState.CLOSED) {
|
|
this.openedAt = null;
|
|
this.requests = [];
|
|
if (this.timeoutId) {
|
|
clearTimeout(this.timeoutId);
|
|
this.timeoutId = undefined;
|
|
}
|
|
}
|
|
this.notifyStateChange(previousState, newState);
|
|
}
|
|
/**
|
|
* Schedule transition to half-open
|
|
*/
|
|
scheduleHalfOpen() {
|
|
if (this.timeoutId) {
|
|
clearTimeout(this.timeoutId);
|
|
}
|
|
this.timeoutId = setTimeout(() => {
|
|
if (this.state === CircuitBreakerState.OPEN) {
|
|
this.transitionTo(CircuitBreakerState.HALF_OPEN);
|
|
}
|
|
}, this.options.timeout);
|
|
}
|
|
/**
|
|
* Notify state change
|
|
*/
|
|
notifyStateChange(from, to) {
|
|
this.emit('stateChange', { from, to });
|
|
this.options.onStateChange?.(from, to);
|
|
}
|
|
/**
|
|
* Clean old requests outside rolling window
|
|
*/
|
|
cleanOldRequests() {
|
|
const cutoff = Date.now() - this.options.rollingWindow;
|
|
this.requests = this.requests.filter((r) => r.timestamp >= cutoff);
|
|
}
|
|
}
|
|
//# sourceMappingURL=circuit-breaker.js.map
|