|
1 | 1 | export enum CircuitState { |
2 | | - CLOSED = 'CLOSED', |
3 | | - OPEN = 'OPEN', |
4 | | - HALF_OPEN = 'HALF_OPEN', |
| 2 | + CLOSED = "CLOSED", |
| 3 | + OPEN = "OPEN", |
| 4 | + HALF_OPEN = "HALF_OPEN", |
5 | 5 | } |
6 | 6 |
|
7 | 7 | export interface CircuitBreakerOptions { |
8 | | - failureThreshold?: number; // Number of failures before opening |
9 | | - cooldownPeriodMs?: number; // Time in ms before attempting half-open |
| 8 | + failureThreshold?: number; |
| 9 | + /** Compatibility alias for the rolling failure window. */ |
| 10 | + windowMs?: number; |
| 11 | + /** Compatibility alias for the OPEN → HALF_OPEN delay. */ |
| 12 | + resetTimeoutMs?: number; |
| 13 | + cooldownPeriodMs?: number; |
10 | 14 | } |
11 | 15 |
|
12 | | -export class CircuitBreakerOpenError extends Error { |
13 | | - public statusCode: number = 503; |
14 | | - constructor(message: string = 'Service unavailable: Circuit breaker is OPEN') { |
15 | | - super(message); |
16 | | - this.name = 'CircuitBreakerOpenError'; |
| 16 | +export class CircuitOpenError extends Error { |
| 17 | + readonly statusCode = 503; |
| 18 | + readonly breakerName: string; |
| 19 | + readonly circuitName: string; |
| 20 | + readonly state: CircuitState; |
| 21 | + readonly openedAt: number; |
| 22 | + readonly halfOpenAfterMs: number; |
| 23 | + |
| 24 | + constructor( |
| 25 | + breakerName: string, |
| 26 | + state: CircuitState, |
| 27 | + openedAt = Date.now(), |
| 28 | + halfOpenAfterMs = 30_000, |
| 29 | + ) { |
| 30 | + super(`Circuit breaker '${breakerName}' is ${state}`); |
| 31 | + this.name = "CircuitOpenError"; |
| 32 | + this.breakerName = breakerName; |
| 33 | + this.circuitName = breakerName; |
| 34 | + this.state = state; |
| 35 | + this.openedAt = openedAt; |
| 36 | + this.halfOpenAfterMs = halfOpenAfterMs; |
17 | 37 | } |
| 38 | +} |
18 | 39 |
|
19 | | - /** @deprecated Legacy alias for {@link CircuitOpenError.circuitName}. */ |
20 | | - get breakerName(): string { |
21 | | - return this.circuitName; |
| 40 | +/** Backwards-compatible name used by the fingerprint endpoint. */ |
| 41 | +export class CircuitBreakerOpenError extends CircuitOpenError { |
| 42 | + constructor(breakerName = "fingerprint", state = CircuitState.OPEN) { |
| 43 | + super(breakerName, state); |
| 44 | + this.name = "CircuitBreakerOpenError"; |
22 | 45 | } |
23 | 46 | } |
24 | 47 |
|
25 | 48 | export class CircuitBreaker { |
26 | | - private state: CircuitState = CircuitState.CLOSED; |
27 | | - private failureCount: number = 0; |
28 | | - private lastStateChange: number = Date.now(); |
| 49 | + private currentState = CircuitState.CLOSED; |
| 50 | + private failures: number[] = []; |
| 51 | + private openedAt = 0; |
| 52 | + private halfOpenProbeInFlight = false; |
29 | 53 | private readonly failureThreshold: number; |
30 | | - private readonly cooldownPeriodMs: number; |
| 54 | + private readonly windowMs: number; |
| 55 | + private readonly resetTimeoutMs: number; |
31 | 56 |
|
32 | | - constructor(options: CircuitBreakerOptions = {}) { |
| 57 | + constructor( |
| 58 | + nameOrOptions: string | CircuitBreakerOptions = {}, |
| 59 | + maybeOptions: CircuitBreakerOptions = {}, |
| 60 | + ) { |
| 61 | + this.name = typeof nameOrOptions === "string" ? nameOrOptions : "circuit"; |
| 62 | + const options = typeof nameOrOptions === "string" ? maybeOptions : nameOrOptions; |
33 | 63 | this.failureThreshold = options.failureThreshold ?? 5; |
34 | | - this.cooldownPeriodMs = options.cooldownPeriodMs ?? 30000; // Default 30 seconds |
| 64 | + this.windowMs = options.windowMs ?? 60_000; |
| 65 | + this.resetTimeoutMs = options.resetTimeoutMs ?? options.cooldownPeriodMs ?? 30_000; |
| 66 | + } |
| 67 | + |
| 68 | + readonly name: string; |
| 69 | + |
| 70 | + get state(): CircuitState { |
| 71 | + return this.getState(); |
35 | 72 | } |
36 | 73 |
|
37 | 74 | public getState(): CircuitState { |
38 | | - if (this.state === CircuitState.OPEN) { |
39 | | - if (Date.now() - this.lastStateChange >= this.cooldownPeriodMs) { |
40 | | - this.state = CircuitState.HALF_OPEN; |
41 | | - } |
| 75 | + if ( |
| 76 | + this.currentState === CircuitState.OPEN && |
| 77 | + Date.now() - this.openedAt >= this.resetTimeoutMs |
| 78 | + ) { |
| 79 | + this.currentState = CircuitState.HALF_OPEN; |
42 | 80 | } |
43 | | - return this.state; |
| 81 | + return this.currentState; |
44 | 82 | } |
45 | 83 |
|
46 | 84 | public async execute<T>(fn: () => Promise<T>): Promise<T> { |
47 | | - const currentState = this.getState(); |
| 85 | + return this.fire(fn); |
| 86 | + } |
48 | 87 |
|
49 | | - if (currentState === CircuitState.OPEN) { |
50 | | - throw new CircuitBreakerOpenError(); |
| 88 | + public async fire<T>(fn: () => Promise<T>): Promise<T> { |
| 89 | + const state = this.getState(); |
| 90 | + if (state === CircuitState.OPEN || (state === CircuitState.HALF_OPEN && this.halfOpenProbeInFlight)) { |
| 91 | + throw new CircuitBreakerOpenError(this.name, state); |
| 92 | + } |
| 93 | + |
| 94 | + if (state === CircuitState.HALF_OPEN) { |
| 95 | + this.halfOpenProbeInFlight = true; |
51 | 96 | } |
52 | 97 |
|
53 | 98 | try { |
54 | 99 | const result = await fn(); |
55 | | - this.onSuccess(); |
| 100 | + this.currentState = CircuitState.CLOSED; |
| 101 | + this.failures = []; |
56 | 102 | return result; |
57 | | - } catch (err) { |
58 | | - this.onFailure(); |
59 | | - throw err; |
| 103 | + } catch (error) { |
| 104 | + this.recordFailure(state); |
| 105 | + throw error; |
| 106 | + } finally { |
| 107 | + if (state === CircuitState.HALF_OPEN) { |
| 108 | + this.halfOpenProbeInFlight = false; |
| 109 | + } |
60 | 110 | } |
61 | 111 | } |
62 | 112 |
|
63 | | - private onSuccess(): void { |
64 | | - this.failureCount = 0; |
65 | | - this.state = CircuitState.CLOSED; |
66 | | - } |
| 113 | + private recordFailure(state: CircuitState): void { |
| 114 | + if (state === CircuitState.HALF_OPEN) { |
| 115 | + this.open(); |
| 116 | + return; |
| 117 | + } |
67 | 118 |
|
68 | | - private onFailure(): void { |
69 | | - this.failureCount += 1; |
70 | | - if (this.failureCount >= this.failureThreshold || this.state === CircuitState.HALF_OPEN) { |
71 | | - this.state = CircuitState.OPEN; |
72 | | - this.lastStateChange = Date.now(); |
| 119 | + const cutoff = Date.now() - this.windowMs; |
| 120 | + this.failures = this.failures.filter((timestamp) => timestamp >= cutoff); |
| 121 | + this.failures.push(Date.now()); |
| 122 | + if (this.failures.length >= this.failureThreshold) { |
| 123 | + this.open(); |
73 | 124 | } |
74 | 125 | } |
75 | 126 |
|
| 127 | + private open(): void { |
| 128 | + this.currentState = CircuitState.OPEN; |
| 129 | + this.openedAt = Date.now(); |
| 130 | + } |
| 131 | + |
76 | 132 | public reset(): void { |
77 | | - this.state = CircuitState.CLOSED; |
78 | | - this.failureCount = 0; |
| 133 | + this.currentState = CircuitState.CLOSED; |
| 134 | + this.failures = []; |
| 135 | + this.openedAt = 0; |
| 136 | + this.halfOpenProbeInFlight = false; |
79 | 137 | } |
| 138 | + |
| 139 | + public snapshot(): { |
| 140 | + state: CircuitState; |
| 141 | + breakerName: string; |
| 142 | + circuitName: string; |
| 143 | + openedAt: number; |
| 144 | + halfOpenAfterMs: number; |
| 145 | + } { |
| 146 | + return { |
| 147 | + state: this.getState(), |
| 148 | + breakerName: this.name, |
| 149 | + circuitName: this.name, |
| 150 | + openedAt: this.openedAt, |
| 151 | + halfOpenAfterMs: this.resetTimeoutMs, |
| 152 | + }; |
| 153 | + } |
| 154 | +} |
| 155 | + |
| 156 | +const breakers = new Map<string, CircuitBreaker>(); |
| 157 | + |
| 158 | +export function getCircuitBreaker( |
| 159 | + name: string, |
| 160 | + options: CircuitBreakerOptions = {}, |
| 161 | +): CircuitBreaker { |
| 162 | + const existing = breakers.get(name); |
| 163 | + if (existing) return existing; |
| 164 | + const breaker = new CircuitBreaker(name, options); |
| 165 | + breakers.set(name, breaker); |
| 166 | + return breaker; |
80 | 167 | } |
81 | 168 |
|
82 | | -// Global/Per-endpoint instances |
83 | | -export const fingerprintCircuitBreaker = new CircuitBreaker({ |
| 169 | +export const fingerprintCircuitBreaker = getCircuitBreaker("fingerprint", { |
84 | 170 | failureThreshold: 3, |
85 | | - cooldownPeriodMs: 15000, |
| 171 | + cooldownPeriodMs: 15_000, |
86 | 172 | }); |
0 commit comments