|
1 | | -/** |
2 | | - * @module lib/circuitBreaker |
3 | | - * |
4 | | - * Per-endpoint circuit breaker with CLOSED → OPEN → HALF_OPEN state machine. |
5 | | - * |
6 | | - * Behaviour |
7 | | - * --------- |
8 | | - * |
9 | | - * CLOSED (normal operation) |
10 | | - * All calls pass through. Failures are counted in a rolling window. Once |
11 | | - * `failureThreshold` failures accumulate within `windowMs`, the breaker |
12 | | - * transitions to OPEN. |
13 | | - * |
14 | | - * OPEN (fast-fail) |
15 | | - * All calls are rejected immediately with a `CircuitOpenError` (callers |
16 | | - * should translate this to HTTP 503). After `resetTimeoutMs` has elapsed |
17 | | - * the breaker transitions to HALF_OPEN to probe whether the downstream has |
18 | | - * recovered. |
19 | | - * |
20 | | - * HALF_OPEN (probe) |
21 | | - * A single call is allowed through as a probe. If it succeeds the breaker |
22 | | - * returns to CLOSED and the failure counts are reset. If it fails the |
23 | | - * breaker returns to OPEN and the reset timeout restarts. |
24 | | - * |
25 | | - * Usage |
26 | | - * ----- |
27 | | - * |
28 | | - * ```ts |
29 | | - * import { CircuitBreaker } from "../lib/circuitBreaker"; |
30 | | - * |
31 | | - * // One breaker instance per downstream endpoint (module-level singleton). |
32 | | - * const dbBreaker = new CircuitBreaker("comments-db"); |
33 | | - * const httpBreaker = new CircuitBreaker("comments-outbound"); |
34 | | - * |
35 | | - * // Wrap any async call with the breaker: |
36 | | - * const result = await dbBreaker.fire(() => listMarketComments(id, cursor, limit)); |
37 | | - * ``` |
38 | | - * |
39 | | - * Tuning |
40 | | - * ------ |
41 | | - * Pass a `CircuitBreakerOptions` object to the constructor: |
42 | | - * |
43 | | - * | Option | Default | Description | |
44 | | - * |--------------------|---------|-----------------------------------------------------| |
45 | | - * | failureThreshold | 5 | Failures in the window before opening | |
46 | | - * | windowMs | 60 000 | Rolling window length in milliseconds | |
47 | | - * | resetTimeoutMs | 30 000 | Time to stay OPEN before probing | |
48 | | - * |
49 | | - * Thread safety |
50 | | - * ------------- |
51 | | - * Node.js is single-threaded, so the integer counters and state transitions |
52 | | - * are inherently atomic within a single process. The breaker is *not* |
53 | | - * distributed — each process maintains its own state. For multi-process |
54 | | - * deployments, a shared backing store (Redis) would be needed. |
55 | | - */ |
56 | | - |
57 | | -import { logger } from "../config/logger"; |
58 | | - |
59 | | -// ── Types & Errors ─────────────────────────────────────────────────────────── |
60 | | - |
61 | | -/** The three states of the circuit breaker. */ |
62 | | -export type CircuitBreakerState = "CLOSED" | "OPEN" | "HALF_OPEN"; |
| 1 | +export enum CircuitState { |
| 2 | + CLOSED = 'CLOSED', |
| 3 | + OPEN = 'OPEN', |
| 4 | + HALF_OPEN = 'HALF_OPEN', |
| 5 | +} |
63 | 6 |
|
64 | | -/** Options accepted by the {@link CircuitBreaker} constructor. */ |
65 | 7 | export interface CircuitBreakerOptions { |
66 | | - /** |
67 | | - * Number of failures in the rolling window that cause the breaker to open. |
68 | | - * @default 5 |
69 | | - */ |
70 | 8 | failureThreshold?: number; |
71 | | - /** |
72 | | - * Length of the rolling failure-count window in milliseconds. |
73 | | - * Failures older than this are discarded. |
74 | | - * @default 60_000 |
75 | | - */ |
76 | | - windowMs?: number; |
77 | | - /** |
78 | | - * How long the breaker stays OPEN before transitioning to HALF_OPEN |
79 | | - * to attempt a probe call. |
80 | | - * @default 30_000 |
81 | | - */ |
82 | 9 | resetTimeoutMs?: number; |
83 | 10 | } |
84 | 11 |
|
85 | | -/** |
86 | | - * Thrown by {@link CircuitBreaker.fire} when the breaker is OPEN or when the |
87 | | - * HALF_OPEN probe slot is already occupied. Callers should map this to HTTP 503. |
88 | | - */ |
89 | | -export class CircuitOpenError extends Error { |
90 | | - public readonly name = "CircuitOpenError"; |
91 | | - public readonly breakerName: string; |
92 | | - public readonly state: CircuitBreakerState; |
93 | | - |
94 | | - constructor(breakerName: string, state: CircuitBreakerState) { |
95 | | - super(`Circuit breaker '${breakerName}' is ${state} — downstream call rejected`); |
96 | | - this.breakerName = breakerName; |
97 | | - this.state = state; |
98 | | - Object.setPrototypeOf(this, CircuitOpenError.prototype); |
| 12 | +export class CircuitBreakerOpenError extends Error { |
| 13 | + constructor(message = 'Service temporarily unavailable due to open circuit breaker') { |
| 14 | + super(message); |
| 15 | + this.name = 'CircuitBreakerOpenError'; |
99 | 16 | } |
100 | 17 | } |
101 | 18 |
|
102 | | -// ── CircuitBreaker class ───────────────────────────────────────────────────── |
103 | | - |
104 | | -/** |
105 | | - * Per-endpoint circuit breaker with CLOSED / OPEN / HALF_OPEN state machine. |
106 | | - * |
107 | | - * Create one instance per logical downstream dependency and reuse it for the |
108 | | - * lifetime of the process. Module-level singletons are the idiomatic pattern. |
109 | | - */ |
110 | 19 | export class CircuitBreaker { |
111 | | - private readonly name: string; |
| 20 | + private state: CircuitState = CircuitState.CLOSED; |
| 21 | + private failureCount = 0; |
112 | 22 | private readonly failureThreshold: number; |
113 | | - private readonly windowMs: number; |
114 | 23 | private readonly resetTimeoutMs: number; |
| 24 | + private lastStateChange: number = Date.now(); |
115 | 25 |
|
116 | | - private _state: CircuitBreakerState = "CLOSED"; |
117 | | - /** Timestamps (ms since epoch) of recent failures within the rolling window. */ |
118 | | - private failureTimes: number[] = []; |
119 | | - /** Wall-clock time at which the OPEN → HALF_OPEN transition may occur. */ |
120 | | - private openedAt: number | null = null; |
121 | | - /** Guards the single probe slot when HALF_OPEN. */ |
122 | | - private halfOpenProbeInFlight = false; |
123 | | - |
124 | | - constructor(name: string, opts: CircuitBreakerOptions = {}) { |
125 | | - this.name = name; |
126 | | - this.failureThreshold = opts.failureThreshold ?? 5; |
127 | | - this.windowMs = opts.windowMs ?? 60_000; |
128 | | - this.resetTimeoutMs = opts.resetTimeoutMs ?? 30_000; |
| 26 | + constructor(options: CircuitBreakerOptions = {}) { |
| 27 | + this.failureThreshold = options.failureThreshold ?? 5; |
| 28 | + this.resetTimeoutMs = options.resetTimeoutMs ?? 10000; |
129 | 29 | } |
130 | 30 |
|
131 | | - // ── Public API ───────────────────────────────────────────────────────────── |
132 | | - |
133 | | - /** The current state of the circuit breaker. */ |
134 | | - get state(): CircuitBreakerState { |
135 | | - this._maybeTransitionToHalfOpen(); |
136 | | - return this._state; |
| 31 | + public getState(): CircuitState { |
| 32 | + if (this.state === CircuitState.OPEN && Date.now() - this.lastStateChange >= this.resetTimeoutMs) { |
| 33 | + this.state = CircuitState.HALF_OPEN; |
| 34 | + } |
| 35 | + return this.state; |
137 | 36 | } |
138 | 37 |
|
139 | | - /** |
140 | | - * Fire the supplied async callable through the breaker. |
141 | | - * |
142 | | - * - CLOSED: calls through, records success/failure. |
143 | | - * - OPEN: throws {@link CircuitOpenError} immediately (fast-fail). |
144 | | - * - HALF_OPEN: allows one probe call; success → CLOSED, failure → OPEN. |
145 | | - * If the probe slot is already occupied, throws {@link CircuitOpenError}. |
146 | | - * |
147 | | - * @param callable Zero-argument async function wrapping the downstream call. |
148 | | - * @returns The resolved value of `callable`. |
149 | | - * @throws {@link CircuitOpenError} when the breaker is OPEN or the HALF_OPEN |
150 | | - * probe slot is busy. |
151 | | - * @throws Whatever `callable` throws when it fails while the breaker is |
152 | | - * CLOSED or serving as the HALF_OPEN probe. |
153 | | - */ |
154 | | - async fire<T>(callable: () => Promise<T>): Promise<T> { |
155 | | - this._maybeTransitionToHalfOpen(); |
| 38 | + public async execute<T>(fn: () => Promise<T>): Promise<T> { |
| 39 | + const currentState = this.getState(); |
156 | 40 |
|
157 | | - if (this._state === "OPEN") { |
158 | | - logger.warn( |
159 | | - { breaker: this.name, state: this._state }, |
160 | | - "circuit_breaker_open_fast_fail", |
161 | | - ); |
162 | | - throw new CircuitOpenError(this.name, this._state); |
163 | | - } |
164 | | - |
165 | | - if (this._state === "HALF_OPEN") { |
166 | | - if (this.halfOpenProbeInFlight) { |
167 | | - // Probe already in flight; reject all other callers. |
168 | | - logger.warn( |
169 | | - { breaker: this.name, state: this._state }, |
170 | | - "circuit_breaker_half_open_probe_busy", |
171 | | - ); |
172 | | - throw new CircuitOpenError(this.name, this._state); |
173 | | - } |
174 | | - this.halfOpenProbeInFlight = true; |
| 41 | + if (currentState === CircuitState.OPEN) { |
| 42 | + throw new CircuitBreakerOpenError(); |
175 | 43 | } |
176 | 44 |
|
177 | 45 | try { |
178 | | - const result = await callable(); |
179 | | - this._onSuccess(); |
| 46 | + const result = await fn(); |
| 47 | + this.onSuccess(); |
180 | 48 | return result; |
181 | 49 | } catch (err) { |
182 | | - this._onFailure(); |
| 50 | + this.onFailure(); |
183 | 51 | throw err; |
184 | | - } finally { |
185 | | - if (this._state === "HALF_OPEN") { |
186 | | - this.halfOpenProbeInFlight = false; |
187 | | - } |
188 | 52 | } |
189 | 53 | } |
190 | 54 |
|
191 | | - /** |
192 | | - * Reset the breaker to CLOSED and clear all failure tracking. |
193 | | - * Intended for test suites; production code should not call this directly. |
194 | | - */ |
195 | | - reset(): void { |
196 | | - this._state = "CLOSED"; |
197 | | - this.failureTimes = []; |
198 | | - this.openedAt = null; |
199 | | - this.halfOpenProbeInFlight = false; |
200 | | - } |
201 | | - |
202 | | - // ── Internal helpers ─────────────────────────────────────────────────────── |
203 | | - |
204 | | - /** Prunes stale timestamps and returns the current failure count. */ |
205 | | - private _prunedFailureCount(): number { |
206 | | - const cutoff = Date.now() - this.windowMs; |
207 | | - this.failureTimes = this.failureTimes.filter((t) => t > cutoff); |
208 | | - return this.failureTimes.length; |
| 55 | + private onSuccess(): void { |
| 56 | + this.failureCount = 0; |
| 57 | + this.state = CircuitState.CLOSED; |
| 58 | + this.lastStateChange = Date.now(); |
209 | 59 | } |
210 | 60 |
|
211 | | - /** Transitions from OPEN to HALF_OPEN if the reset timeout has elapsed. */ |
212 | | - private _maybeTransitionToHalfOpen(): void { |
213 | | - if ( |
214 | | - this._state === "OPEN" && |
215 | | - this.openedAt !== null && |
216 | | - Date.now() - this.openedAt >= this.resetTimeoutMs |
217 | | - ) { |
218 | | - this._state = "HALF_OPEN"; |
219 | | - this.halfOpenProbeInFlight = false; |
220 | | - logger.info( |
221 | | - { breaker: this.name, state: "HALF_OPEN" }, |
222 | | - "circuit_breaker_half_open", |
223 | | - ); |
| 61 | + private onFailure(): void { |
| 62 | + this.failureCount += 1; |
| 63 | + if (this.failureCount >= this.failureThreshold || this.state === CircuitState.HALF_OPEN) { |
| 64 | + this.state = CircuitState.OPEN; |
| 65 | + this.lastStateChange = Date.now(); |
224 | 66 | } |
225 | 67 | } |
226 | 68 |
|
227 | | - private _onSuccess(): void { |
228 | | - if (this._state === "HALF_OPEN") { |
229 | | - logger.info( |
230 | | - { breaker: this.name }, |
231 | | - "circuit_breaker_probe_success_closing", |
232 | | - ); |
233 | | - } |
234 | | - // Any success resets the breaker fully. |
235 | | - this._state = "CLOSED"; |
236 | | - this.failureTimes = []; |
237 | | - this.openedAt = null; |
| 69 | + public reset(): void { |
| 70 | + this.state = CircuitState.CLOSED; |
| 71 | + this.failureCount = 0; |
| 72 | + this.lastStateChange = Date.now(); |
238 | 73 | } |
239 | | - |
240 | | - private _onFailure(): void { |
241 | | - const now = Date.now(); |
242 | | - |
243 | | - if (this._state === "HALF_OPEN") { |
244 | | - // Probe failed — return to OPEN and restart the reset timer. |
245 | | - this._state = "OPEN"; |
246 | | - this.openedAt = now; |
247 | | - logger.warn( |
248 | | - { breaker: this.name, state: "OPEN" }, |
249 | | - "circuit_breaker_probe_failed_reopened", |
250 | | - ); |
251 | | - return; |
252 | | - } |
253 | | - |
254 | | - // CLOSED — record failure and check threshold. |
255 | | - this.failureTimes.push(now); |
256 | | - const count = this._prunedFailureCount(); |
257 | | - |
258 | | - if (count >= this.failureThreshold) { |
259 | | - this._state = "OPEN"; |
260 | | - this.openedAt = now; |
261 | | - logger.warn( |
262 | | - { |
263 | | - breaker: this.name, |
264 | | - failures: count, |
265 | | - threshold: this.failureThreshold, |
266 | | - state: "OPEN", |
267 | | - }, |
268 | | - "circuit_breaker_opened", |
269 | | - ); |
270 | | - } |
271 | | - } |
272 | | - |
273 | | - snapshot(): { state: CircuitBreakerState; failures: number; halfOpenAfterMs: number } { |
274 | | - return { |
275 | | - state: this.state, |
276 | | - failures: this.failureTimes.length, |
277 | | - halfOpenAfterMs: this.resetTimeoutMs, |
278 | | - }; |
279 | | - } |
280 | | -} |
281 | | - |
282 | | -// ── Global Registry ───────────────────────────────────────────────────────── |
283 | | - |
284 | | -const breakers = new Map<string, CircuitBreaker>(); |
285 | | - |
286 | | -export function getCircuitBreaker(name: string, opts?: CircuitBreakerOptions): CircuitBreaker { |
287 | | - let breaker = breakers.get(name); |
288 | | - if (!breaker) { |
289 | | - breaker = new CircuitBreaker(name, opts); |
290 | | - breakers.set(name, breaker); |
291 | | - } |
292 | | - return breaker; |
293 | | -} |
294 | | - |
295 | | -export function resetCircuitBreakersForTests(): void { |
296 | | - breakers.clear(); |
297 | | -} |
298 | | - |
299 | | -export function forceCircuitStateForTests( |
300 | | - name: string, |
301 | | - state: CircuitBreakerState, |
302 | | - opts?: { halfOpenAfterMs?: number } |
303 | | -): void { |
304 | | - const breaker = getCircuitBreaker(name); |
305 | | - // @ts-ignore - access private fields for test overrides |
306 | | - breaker._state = state; |
307 | | - // @ts-ignore |
308 | | - breaker.openedAt = state === "OPEN" || state === "HALF_OPEN" ? Date.now() : null; |
309 | | - // @ts-ignore |
310 | | - if (opts?.halfOpenAfterMs !== undefined) { breaker.resetTimeoutMs = opts.halfOpenAfterMs; } |
311 | 74 | } |
0 commit comments