239 lines
6.3 KiB
TypeScript
239 lines
6.3 KiB
TypeScript
import type { TCircuitState, IUpstreamResilienceConfig } from './interfaces.upstream.js';
|
|
import { DEFAULT_RESILIENCE_CONFIG } from './interfaces.upstream.js';
|
|
|
|
/**
|
|
* Circuit breaker implementation for upstream resilience.
|
|
*
|
|
* States:
|
|
* - CLOSED: Normal operation, requests pass through
|
|
* - OPEN: Circuit is tripped, requests fail fast
|
|
* - HALF_OPEN: Testing if upstream has recovered
|
|
*
|
|
* Transitions:
|
|
* - CLOSED → OPEN: When failure count exceeds threshold
|
|
* - OPEN → HALF_OPEN: After reset timeout expires
|
|
* - HALF_OPEN → CLOSED: On successful request
|
|
* - HALF_OPEN → OPEN: On failed request
|
|
*/
|
|
export class CircuitBreaker {
|
|
/** Unique identifier for logging and metrics */
|
|
public readonly id: string;
|
|
|
|
/** Current circuit state */
|
|
private state: TCircuitState = 'CLOSED';
|
|
|
|
/** Count of consecutive failures */
|
|
private failureCount: number = 0;
|
|
|
|
/** Timestamp when circuit was opened */
|
|
private openedAt: number = 0;
|
|
|
|
/** Number of successful requests in half-open state */
|
|
private halfOpenSuccesses: number = 0;
|
|
|
|
/** Configuration */
|
|
private readonly config: IUpstreamResilienceConfig;
|
|
|
|
/** Number of successes required to close circuit from half-open */
|
|
private readonly halfOpenThreshold: number = 2;
|
|
|
|
constructor(id: string, config?: Partial<IUpstreamResilienceConfig>) {
|
|
this.id = id;
|
|
this.config = { ...DEFAULT_RESILIENCE_CONFIG, ...config };
|
|
}
|
|
|
|
/**
|
|
* Get current circuit state.
|
|
*/
|
|
public getState(): TCircuitState {
|
|
// Check if we should transition from OPEN to HALF_OPEN
|
|
if (this.state === 'OPEN') {
|
|
const elapsed = Date.now() - this.openedAt;
|
|
if (elapsed >= this.config.circuitBreakerResetMs) {
|
|
this.transitionTo('HALF_OPEN');
|
|
}
|
|
}
|
|
return this.state;
|
|
}
|
|
|
|
/**
|
|
* Check if circuit allows requests.
|
|
* Returns true if requests should be allowed.
|
|
*/
|
|
public canRequest(): boolean {
|
|
const currentState = this.getState();
|
|
return currentState !== 'OPEN';
|
|
}
|
|
|
|
/**
|
|
* Record a successful request.
|
|
* May transition circuit from HALF_OPEN to CLOSED.
|
|
*/
|
|
public recordSuccess(): void {
|
|
if (this.state === 'HALF_OPEN') {
|
|
this.halfOpenSuccesses++;
|
|
if (this.halfOpenSuccesses >= this.halfOpenThreshold) {
|
|
this.transitionTo('CLOSED');
|
|
}
|
|
} else if (this.state === 'CLOSED') {
|
|
// Reset failure count on success
|
|
this.failureCount = 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a failed request.
|
|
* May transition circuit from CLOSED/HALF_OPEN to OPEN.
|
|
*/
|
|
public recordFailure(): void {
|
|
if (this.state === 'HALF_OPEN') {
|
|
// Any failure in half-open immediately opens circuit
|
|
this.transitionTo('OPEN');
|
|
} else if (this.state === 'CLOSED') {
|
|
this.failureCount++;
|
|
if (this.failureCount >= this.config.circuitBreakerThreshold) {
|
|
this.transitionTo('OPEN');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Force circuit to open state.
|
|
* Useful for manual intervention or external health checks.
|
|
*/
|
|
public forceOpen(): void {
|
|
this.transitionTo('OPEN');
|
|
}
|
|
|
|
/**
|
|
* Force circuit to closed state.
|
|
* Useful for manual intervention after fixing upstream issues.
|
|
*/
|
|
public forceClose(): void {
|
|
this.transitionTo('CLOSED');
|
|
}
|
|
|
|
/**
|
|
* Reset circuit to initial state.
|
|
*/
|
|
public reset(): void {
|
|
this.state = 'CLOSED';
|
|
this.failureCount = 0;
|
|
this.openedAt = 0;
|
|
this.halfOpenSuccesses = 0;
|
|
}
|
|
|
|
/**
|
|
* Get circuit metrics for monitoring.
|
|
*/
|
|
public getMetrics(): ICircuitBreakerMetrics {
|
|
return {
|
|
id: this.id,
|
|
state: this.getState(),
|
|
failureCount: this.failureCount,
|
|
openedAt: this.openedAt > 0 ? new Date(this.openedAt) : null,
|
|
timeUntilHalfOpen: this.state === 'OPEN'
|
|
? Math.max(0, this.config.circuitBreakerResetMs - (Date.now() - this.openedAt))
|
|
: 0,
|
|
halfOpenSuccesses: this.halfOpenSuccesses,
|
|
threshold: this.config.circuitBreakerThreshold,
|
|
resetMs: this.config.circuitBreakerResetMs,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Transition to a new state with proper cleanup.
|
|
*/
|
|
private transitionTo(newState: TCircuitState): void {
|
|
const previousState = this.state;
|
|
this.state = newState;
|
|
|
|
switch (newState) {
|
|
case 'OPEN':
|
|
this.openedAt = Date.now();
|
|
this.halfOpenSuccesses = 0;
|
|
break;
|
|
case 'HALF_OPEN':
|
|
this.halfOpenSuccesses = 0;
|
|
break;
|
|
case 'CLOSED':
|
|
this.failureCount = 0;
|
|
this.openedAt = 0;
|
|
this.halfOpenSuccesses = 0;
|
|
break;
|
|
}
|
|
|
|
// Log state transition (useful for debugging and monitoring)
|
|
// In production, this would emit events or metrics
|
|
if (previousState !== newState) {
|
|
// State changed - could emit event here
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Metrics for circuit breaker monitoring.
|
|
*/
|
|
export interface ICircuitBreakerMetrics {
|
|
/** Circuit breaker identifier */
|
|
id: string;
|
|
/** Current state */
|
|
state: TCircuitState;
|
|
/** Number of consecutive failures */
|
|
failureCount: number;
|
|
/** When circuit was opened (null if never opened) */
|
|
openedAt: Date | null;
|
|
/** Milliseconds until circuit transitions to half-open (0 if not open) */
|
|
timeUntilHalfOpen: number;
|
|
/** Number of successes in half-open state */
|
|
halfOpenSuccesses: number;
|
|
/** Failure threshold for opening circuit */
|
|
threshold: number;
|
|
/** Reset timeout in milliseconds */
|
|
resetMs: number;
|
|
}
|
|
|
|
/**
|
|
* Execute a function with circuit breaker protection.
|
|
*
|
|
* @param breaker The circuit breaker to use
|
|
* @param fn The async function to execute
|
|
* @param fallback Optional fallback function when circuit is open
|
|
* @returns The result of fn or fallback
|
|
* @throws CircuitOpenError if circuit is open and no fallback provided
|
|
*/
|
|
export async function withCircuitBreaker<T>(
|
|
breaker: CircuitBreaker,
|
|
fn: () => Promise<T>,
|
|
fallback?: () => Promise<T>,
|
|
): Promise<T> {
|
|
if (!breaker.canRequest()) {
|
|
if (fallback) {
|
|
return fallback();
|
|
}
|
|
throw new CircuitOpenError(breaker.id);
|
|
}
|
|
|
|
try {
|
|
const result = await fn();
|
|
breaker.recordSuccess();
|
|
return result;
|
|
} catch (error) {
|
|
breaker.recordFailure();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error thrown when circuit is open and no fallback is provided.
|
|
*/
|
|
export class CircuitOpenError extends Error {
|
|
public readonly circuitId: string;
|
|
|
|
constructor(circuitId: string) {
|
|
super(`Circuit breaker '${circuitId}' is open`);
|
|
this.name = 'CircuitOpenError';
|
|
this.circuitId = circuitId;
|
|
}
|
|
}
|