261 lines
6.8 KiB
TypeScript
261 lines
6.8 KiB
TypeScript
/**
|
|
* Circuit Breaker pattern implementation
|
|
*
|
|
* Prevents cascading failures by temporarily blocking requests
|
|
* to a failing service, giving it time to recover.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const breaker = new CircuitBreaker({ failureThreshold: 5 });
|
|
*
|
|
* try {
|
|
* const result = await breaker.execute(async () => {
|
|
* return await fetch('https://api.example.com');
|
|
* });
|
|
* } catch (error) {
|
|
* if (error instanceof CircuitBreakerError) {
|
|
* console.log('Circuit is open, service unavailable');
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
|
|
/**
|
|
* Circuit breaker states
|
|
*/
|
|
export enum CircuitState {
|
|
/** Circuit is closed - requests pass through normally */
|
|
CLOSED = 'CLOSED',
|
|
/** Circuit is open - all requests are immediately rejected */
|
|
OPEN = 'OPEN',
|
|
/** Circuit is half-open - one test request is allowed */
|
|
HALF_OPEN = 'HALF_OPEN',
|
|
}
|
|
|
|
/**
|
|
* Configuration options for circuit breaker
|
|
*/
|
|
export interface CircuitBreakerOptions {
|
|
/** Number of consecutive failures before opening circuit (default: 5) */
|
|
failureThreshold?: number;
|
|
/** Time in ms to wait before attempting recovery (default: 60000) */
|
|
resetTimeoutMs?: number;
|
|
/** Time window in ms for monitoring failures (default: 120000) */
|
|
monitoringWindowMs?: number;
|
|
}
|
|
|
|
/**
|
|
* Custom error thrown when circuit is open
|
|
*/
|
|
export class CircuitBreakerError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly state: CircuitState
|
|
) {
|
|
super(message);
|
|
this.name = 'CircuitBreakerError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tracks failure events with timestamps
|
|
*/
|
|
interface FailureRecord {
|
|
timestamp: number;
|
|
error: Error;
|
|
}
|
|
|
|
/**
|
|
* Circuit Breaker implementation
|
|
*
|
|
* Monitors operation failures and automatically opens the circuit
|
|
* when failure threshold is exceeded, preventing further attempts
|
|
* until a reset timeout has elapsed.
|
|
*/
|
|
export class CircuitBreaker {
|
|
private state: CircuitState = CircuitState.CLOSED;
|
|
private failures: FailureRecord[] = [];
|
|
private openedAt: number | null = null;
|
|
private successCount = 0;
|
|
private failureCount = 0;
|
|
|
|
private readonly failureThreshold: number;
|
|
private readonly resetTimeoutMs: number;
|
|
private readonly monitoringWindowMs: number;
|
|
|
|
constructor(options?: CircuitBreakerOptions) {
|
|
this.failureThreshold = options?.failureThreshold ?? 5;
|
|
this.resetTimeoutMs = options?.resetTimeoutMs ?? 60000;
|
|
this.monitoringWindowMs = options?.monitoringWindowMs ?? 120000;
|
|
|
|
console.log('[CircuitBreaker] Initialized', {
|
|
failureThreshold: this.failureThreshold,
|
|
resetTimeoutMs: this.resetTimeoutMs,
|
|
monitoringWindowMs: this.monitoringWindowMs,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get current circuit state
|
|
*/
|
|
getState(): CircuitState {
|
|
return this.state;
|
|
}
|
|
|
|
/**
|
|
* Get circuit statistics
|
|
*/
|
|
getStats() {
|
|
const lastFailure = this.failures.length > 0
|
|
? this.failures[this.failures.length - 1]
|
|
: null;
|
|
|
|
return {
|
|
state: this.state,
|
|
failures: this.failures.length,
|
|
lastFailureTime: lastFailure ? new Date(lastFailure.timestamp) : undefined,
|
|
stats: {
|
|
totalRequests: this.successCount + this.failureCount,
|
|
totalFailures: this.failureCount,
|
|
totalSuccesses: this.successCount,
|
|
},
|
|
config: {
|
|
failureThreshold: this.failureThreshold,
|
|
resetTimeoutMs: this.resetTimeoutMs,
|
|
monitoringWindowMs: this.monitoringWindowMs,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Manually reset the circuit to closed state
|
|
*/
|
|
reset(): void {
|
|
console.log('[CircuitBreaker] Manual reset');
|
|
this.state = CircuitState.CLOSED;
|
|
this.failures = [];
|
|
this.openedAt = null;
|
|
this.successCount = 0;
|
|
this.failureCount = 0;
|
|
}
|
|
|
|
/**
|
|
* Remove old failure records outside monitoring window
|
|
*/
|
|
private cleanupOldFailures(): void {
|
|
const now = Date.now();
|
|
const cutoff = now - this.monitoringWindowMs;
|
|
|
|
this.failures = this.failures.filter(
|
|
record => record.timestamp > cutoff
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if circuit should transition to half-open state
|
|
*/
|
|
private checkResetTimeout(): void {
|
|
if (this.state === CircuitState.OPEN && this.openedAt !== null) {
|
|
const now = Date.now();
|
|
const elapsed = now - this.openedAt;
|
|
|
|
if (elapsed >= this.resetTimeoutMs) {
|
|
console.log('[CircuitBreaker] Reset timeout reached, transitioning to HALF_OPEN');
|
|
this.state = CircuitState.HALF_OPEN;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a successful operation
|
|
*/
|
|
private onSuccess(): void {
|
|
this.successCount++;
|
|
|
|
if (this.state === CircuitState.HALF_OPEN) {
|
|
console.log('[CircuitBreaker] Half-open test succeeded, closing circuit');
|
|
this.state = CircuitState.CLOSED;
|
|
this.failures = [];
|
|
this.openedAt = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a failed operation
|
|
*/
|
|
private onFailure(error: Error): void {
|
|
this.failureCount++;
|
|
|
|
const now = Date.now();
|
|
this.failures.push({ timestamp: now, error });
|
|
|
|
// Clean up old failures
|
|
this.cleanupOldFailures();
|
|
|
|
// If in half-open state, one failure reopens the circuit
|
|
if (this.state === CircuitState.HALF_OPEN) {
|
|
console.log('[CircuitBreaker] Half-open test failed, reopening circuit');
|
|
this.state = CircuitState.OPEN;
|
|
this.openedAt = now;
|
|
return;
|
|
}
|
|
|
|
// Check if we should open the circuit
|
|
if (this.state === CircuitState.CLOSED) {
|
|
if (this.failures.length >= this.failureThreshold) {
|
|
console.log(
|
|
`[CircuitBreaker] Failure threshold (${this.failureThreshold}) exceeded, opening circuit`
|
|
);
|
|
this.state = CircuitState.OPEN;
|
|
this.openedAt = now;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a function through the circuit breaker
|
|
*
|
|
* @param fn - Async function to execute
|
|
* @returns Promise resolving to the function's result
|
|
* @throws CircuitBreakerError if circuit is open
|
|
* @throws Original error if function fails
|
|
*/
|
|
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
|
// Check if we should transition to half-open
|
|
this.checkResetTimeout();
|
|
|
|
// If circuit is open, reject immediately
|
|
if (this.state === CircuitState.OPEN) {
|
|
const error = new CircuitBreakerError(
|
|
'Circuit breaker is open - service unavailable',
|
|
this.state
|
|
);
|
|
console.log('[CircuitBreaker] Request blocked - circuit is OPEN');
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
// Execute the function
|
|
const result = await fn();
|
|
|
|
// Record success
|
|
this.onSuccess();
|
|
|
|
return result;
|
|
} catch (error) {
|
|
// Record failure
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
this.onFailure(err);
|
|
|
|
// Log failure
|
|
console.error(
|
|
`[CircuitBreaker] Operation failed (${this.failures.length}/${this.failureThreshold} failures):`,
|
|
err.message
|
|
);
|
|
|
|
// Re-throw the original error
|
|
throw err;
|
|
}
|
|
}
|
|
}
|