307 lines
6.9 KiB
TypeScript
307 lines
6.9 KiB
TypeScript
/**
|
|
* Circuit breaker states
|
|
*/
|
|
export enum CircuitState {
|
|
/** Circuit is closed, requests flow normally */
|
|
CLOSED = 'closed',
|
|
|
|
/** Circuit is open, requests are rejected immediately */
|
|
OPEN = 'open',
|
|
|
|
/** Circuit is half-open, testing if service recovered */
|
|
HALF_OPEN = 'half_open',
|
|
}
|
|
|
|
/**
|
|
* Circuit breaker configuration
|
|
*/
|
|
export interface CircuitBreakerConfig {
|
|
/** Number of failures before opening circuit */
|
|
failureThreshold: number;
|
|
|
|
/** Number of successes in half-open state before closing */
|
|
successThreshold: number;
|
|
|
|
/** Time in milliseconds circuit stays open before attempting half-open */
|
|
timeout: number;
|
|
|
|
/** Time window in milliseconds for counting failures */
|
|
rollingWindow: number;
|
|
|
|
/** Whether circuit breaker is enabled */
|
|
enabled: boolean;
|
|
}
|
|
|
|
/**
|
|
* Default circuit breaker configuration
|
|
*/
|
|
export const DEFAULT_CIRCUIT_BREAKER_CONFIG: CircuitBreakerConfig = {
|
|
failureThreshold: 5,
|
|
successThreshold: 2,
|
|
timeout: 60000, // 1 minute
|
|
rollingWindow: 10000, // 10 seconds
|
|
enabled: true,
|
|
};
|
|
|
|
/**
|
|
* Circuit breaker error thrown when circuit is open
|
|
*/
|
|
export class CircuitBreakerOpenError extends Error {
|
|
constructor(
|
|
public readonly circuitName: string,
|
|
public readonly nextAttemptTime: Date
|
|
) {
|
|
super(
|
|
`Circuit breaker "${circuitName}" is OPEN. Next attempt at ${nextAttemptTime.toISOString()}`
|
|
);
|
|
this.name = 'CircuitBreakerOpenError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Failure record for tracking
|
|
*/
|
|
interface FailureRecord {
|
|
timestamp: number;
|
|
error: Error;
|
|
}
|
|
|
|
/**
|
|
* Circuit breaker for preventing cascading failures
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const breaker = new CircuitBreaker('elasticsearch', {
|
|
* failureThreshold: 5,
|
|
* timeout: 60000,
|
|
* });
|
|
*
|
|
* try {
|
|
* const result = await breaker.execute(async () => {
|
|
* return await someElasticsearchOperation();
|
|
* });
|
|
* } catch (error) {
|
|
* if (error instanceof CircuitBreakerOpenError) {
|
|
* // Circuit is open, handle gracefully
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
export class CircuitBreaker {
|
|
private config: CircuitBreakerConfig;
|
|
private state: CircuitState = CircuitState.CLOSED;
|
|
private failures: FailureRecord[] = [];
|
|
private successCount = 0;
|
|
private openedAt?: number;
|
|
private nextAttemptTime?: number;
|
|
|
|
constructor(
|
|
private name: string,
|
|
config: Partial<CircuitBreakerConfig> = {}
|
|
) {
|
|
this.config = { ...DEFAULT_CIRCUIT_BREAKER_CONFIG, ...config };
|
|
}
|
|
|
|
/**
|
|
* Execute an operation through the circuit breaker
|
|
*/
|
|
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
|
if (!this.config.enabled) {
|
|
return operation();
|
|
}
|
|
|
|
// Check circuit state
|
|
this.updateState();
|
|
|
|
if (this.state === CircuitState.OPEN) {
|
|
const nextAttempt = this.nextAttemptTime
|
|
? new Date(this.nextAttemptTime)
|
|
: new Date(Date.now() + this.config.timeout);
|
|
throw new CircuitBreakerOpenError(this.name, nextAttempt);
|
|
}
|
|
|
|
try {
|
|
const result = await operation();
|
|
this.onSuccess();
|
|
return result;
|
|
} catch (error) {
|
|
this.onFailure(error as Error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle successful operation
|
|
*/
|
|
private onSuccess(): void {
|
|
this.removeOldFailures();
|
|
|
|
if (this.state === CircuitState.HALF_OPEN) {
|
|
this.successCount++;
|
|
|
|
if (this.successCount >= this.config.successThreshold) {
|
|
this.transitionTo(CircuitState.CLOSED);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle failed operation
|
|
*/
|
|
private onFailure(error: Error): void {
|
|
this.failures.push({
|
|
timestamp: Date.now(),
|
|
error,
|
|
});
|
|
|
|
this.removeOldFailures();
|
|
|
|
if (this.state === CircuitState.HALF_OPEN) {
|
|
// Any failure in half-open state opens the circuit immediately
|
|
this.transitionTo(CircuitState.OPEN);
|
|
} else if (this.state === CircuitState.CLOSED) {
|
|
// Check if we've exceeded failure threshold
|
|
if (this.failures.length >= this.config.failureThreshold) {
|
|
this.transitionTo(CircuitState.OPEN);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update circuit state based on time
|
|
*/
|
|
private updateState(): void {
|
|
if (this.state === CircuitState.OPEN && this.nextAttemptTime) {
|
|
if (Date.now() >= this.nextAttemptTime) {
|
|
this.transitionTo(CircuitState.HALF_OPEN);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Transition to a new state
|
|
*/
|
|
private transitionTo(newState: CircuitState): void {
|
|
const previousState = this.state;
|
|
this.state = newState;
|
|
|
|
switch (newState) {
|
|
case CircuitState.OPEN:
|
|
this.openedAt = Date.now();
|
|
this.nextAttemptTime = Date.now() + this.config.timeout;
|
|
this.successCount = 0;
|
|
break;
|
|
|
|
case CircuitState.HALF_OPEN:
|
|
this.successCount = 0;
|
|
break;
|
|
|
|
case CircuitState.CLOSED:
|
|
this.failures = [];
|
|
this.successCount = 0;
|
|
this.openedAt = undefined;
|
|
this.nextAttemptTime = undefined;
|
|
break;
|
|
}
|
|
|
|
if (previousState !== newState) {
|
|
this.onStateChange(previousState, newState);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove failures outside the rolling window
|
|
*/
|
|
private removeOldFailures(): void {
|
|
const cutoff = Date.now() - this.config.rollingWindow;
|
|
this.failures = this.failures.filter((f) => f.timestamp >= cutoff);
|
|
}
|
|
|
|
/**
|
|
* Callback when state changes (can be overridden)
|
|
*/
|
|
protected onStateChange(from: CircuitState, to: CircuitState): void {
|
|
// Override in subclass or use getState() to monitor
|
|
console.log(`Circuit breaker "${this.name}" transitioned from ${from} to ${to}`);
|
|
}
|
|
|
|
/**
|
|
* Get current circuit state
|
|
*/
|
|
getState(): CircuitState {
|
|
this.updateState();
|
|
return this.state;
|
|
}
|
|
|
|
/**
|
|
* Get circuit statistics
|
|
*/
|
|
getStats(): {
|
|
state: CircuitState;
|
|
failureCount: number;
|
|
successCount: number;
|
|
openedAt?: Date;
|
|
nextAttemptTime?: Date;
|
|
} {
|
|
this.removeOldFailures();
|
|
this.updateState();
|
|
|
|
return {
|
|
state: this.state,
|
|
failureCount: this.failures.length,
|
|
successCount: this.successCount,
|
|
...(this.openedAt && { openedAt: new Date(this.openedAt) }),
|
|
...(this.nextAttemptTime && { nextAttemptTime: new Date(this.nextAttemptTime) }),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Manually open the circuit
|
|
*/
|
|
open(): void {
|
|
this.transitionTo(CircuitState.OPEN);
|
|
}
|
|
|
|
/**
|
|
* Manually close the circuit
|
|
*/
|
|
close(): void {
|
|
this.transitionTo(CircuitState.CLOSED);
|
|
}
|
|
|
|
/**
|
|
* Reset the circuit breaker
|
|
*/
|
|
reset(): void {
|
|
this.failures = [];
|
|
this.successCount = 0;
|
|
this.openedAt = undefined;
|
|
this.nextAttemptTime = undefined;
|
|
this.state = CircuitState.CLOSED;
|
|
}
|
|
|
|
/**
|
|
* Check if circuit is open
|
|
*/
|
|
isOpen(): boolean {
|
|
this.updateState();
|
|
return this.state === CircuitState.OPEN;
|
|
}
|
|
|
|
/**
|
|
* Check if circuit is closed
|
|
*/
|
|
isClosed(): boolean {
|
|
this.updateState();
|
|
return this.state === CircuitState.CLOSED;
|
|
}
|
|
|
|
/**
|
|
* Check if circuit is half-open
|
|
*/
|
|
isHalfOpen(): boolean {
|
|
this.updateState();
|
|
return this.state === CircuitState.HALF_OPEN;
|
|
}
|
|
}
|