Skip to content

Commit d0c9204

Browse files
marcstraubeclaude
andauthored
feat(network): add circuit breaker pattern to RetryQueue (#57)
## Summary - Add circuit breaker pattern to RetryQueue for fast-fail after sustained failures - Three states: `closed` (normal) → `open` (fast-fail) → `half-open` (probe) → `closed` - Configurable via `circuitBreaker: { threshold, cooldownMs? }` option - Per-attempt failure counting across all operations - State change events via `onCircuitStateChange(handler)` - Manual reset via `resetCircuit()` - Disabled by default (fully backwards compatible) - 42 new tests Closes #7 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e3cd5bb commit d0c9204

4 files changed

Lines changed: 616 additions & 4 deletions

File tree

src/core/errors/NetworkError.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ export type NetworkErrorCode =
2323
| 'NETWORK_MAX_RETRIES'
2424
| 'NETWORK_REQUEST_FAILED'
2525
| 'NETWORK_ABORTED'
26-
| 'NETWORK_INVALID_OPTIONS';
26+
| 'NETWORK_INVALID_OPTIONS'
27+
| 'NETWORK_CIRCUIT_OPEN';
2728

2829
export class NetworkError extends BrowserUtilsError {
2930
readonly code: NetworkErrorCode;
@@ -82,4 +83,11 @@ export class NetworkError extends BrowserUtilsError {
8283
static aborted(): NetworkError {
8384
return new NetworkError('NETWORK_ABORTED', 'Network request was aborted');
8485
}
86+
87+
/**
88+
* Circuit breaker is open.
89+
*/
90+
static circuitOpen(): NetworkError {
91+
return new NetworkError('NETWORK_CIRCUIT_OPEN', 'Circuit breaker is open');
92+
}
8593
}

src/network/RetryQueue.ts

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ import { NetworkStatus } from './NetworkStatus.js';
5555

5656
export type BackoffStrategy = 'linear' | 'exponential' | 'constant';
5757

58+
export type CircuitState = 'closed' | 'open' | 'half-open';
59+
60+
export interface CircuitBreakerOptions {
61+
/** Number of consecutive failures before opening the circuit. */
62+
readonly threshold: number;
63+
/**
64+
* Time in ms before transitioning from open to half-open.
65+
* @default 30000
66+
*/
67+
readonly cooldownMs?: number;
68+
}
69+
5870
export interface RetryQueueOptions {
5971
/**
6072
* Maximum retry attempts.
@@ -92,6 +104,12 @@ export interface RetryQueueOptions {
92104
*/
93105
readonly jitter?: boolean;
94106

107+
/**
108+
* Circuit breaker configuration (optional, disabled by default).
109+
* Opens the circuit after consecutive failures to prevent cascading failures.
110+
*/
111+
readonly circuitBreaker?: CircuitBreakerOptions;
112+
95113
/**
96114
* Rate limiting configuration (optional, disabled by default).
97115
* Limits how many operations can be processed within a time window
@@ -115,6 +133,7 @@ export interface RetryStats {
115133
readonly failed: number;
116134
readonly retries: number;
117135
readonly paused: boolean;
136+
readonly circuitState: CircuitState;
118137
}
119138

120139
interface QueuedOperation<T> {
@@ -125,11 +144,12 @@ interface QueuedOperation<T> {
125144
}
126145

127146
export class RetryQueue {
128-
private readonly options: Required<Omit<RetryQueueOptions, 'rateLimit'>> & {
147+
private readonly options: Required<Omit<RetryQueueOptions, 'rateLimit' | 'circuitBreaker'>> & {
129148
readonly rateLimit?: {
130149
readonly maxRequestsPerWindow: number;
131150
readonly windowMs: number;
132151
};
152+
readonly circuitBreaker?: CircuitBreakerOptions;
133153
};
134154
private readonly queue: Array<QueuedOperation<unknown>> = [];
135155
private processing = false;
@@ -138,6 +158,10 @@ export class RetryQueue {
138158
private retryHandlers: Array<(attempt: number, error: unknown) => void> = [];
139159
private networkCleanup: CleanupFn | null = null;
140160
private readonly operationTimestamps: number[] = [];
161+
private circuitState: CircuitState = 'closed';
162+
private consecutiveFailures = 0;
163+
private circuitCooldownTimer: ReturnType<typeof setTimeout> | null = null;
164+
private circuitStateHandlers: Array<(state: CircuitState) => void> = [];
141165

142166
private constructor(options: RetryQueueOptions = {}) {
143167
this.options = {
@@ -147,6 +171,7 @@ export class RetryQueue {
147171
maxDelay: options.maxDelay ?? 30000,
148172
networkAware: options.networkAware ?? true,
149173
jitter: options.jitter ?? true,
174+
circuitBreaker: options.circuitBreaker,
150175
rateLimit: options.rateLimit,
151176
};
152177

@@ -156,6 +181,23 @@ export class RetryQueue {
156181
if (this.options.maxDelay <= 0) {
157182
throw new NetworkError('NETWORK_INVALID_OPTIONS', 'maxDelay must be positive');
158183
}
184+
if (this.options.circuitBreaker) {
185+
if (this.options.circuitBreaker.threshold <= 0) {
186+
throw new NetworkError(
187+
'NETWORK_INVALID_OPTIONS',
188+
'circuitBreaker threshold must be positive'
189+
);
190+
}
191+
if (
192+
this.options.circuitBreaker.cooldownMs !== undefined &&
193+
this.options.circuitBreaker.cooldownMs <= 0
194+
) {
195+
throw new NetworkError(
196+
'NETWORK_INVALID_OPTIONS',
197+
'circuitBreaker cooldownMs must be positive'
198+
);
199+
}
200+
}
159201

160202
if (this.options.networkAware) {
161203
this.setupNetworkAwareness();
@@ -227,6 +269,10 @@ export class RetryQueue {
227269
destroy(): void {
228270
this.clear();
229271
this.retryHandlers = [];
272+
this.circuitStateHandlers = [];
273+
this.clearCooldownTimer();
274+
this.consecutiveFailures = 0;
275+
this.circuitState = 'closed';
230276

231277
if (this.networkCleanup !== null) {
232278
this.networkCleanup();
@@ -253,6 +299,37 @@ export class RetryQueue {
253299
};
254300
}
255301

302+
/**
303+
* Listen for circuit breaker state changes.
304+
* @returns Cleanup function
305+
*/
306+
onCircuitStateChange(handler: (state: CircuitState) => void): CleanupFn {
307+
this.circuitStateHandlers.push(handler);
308+
309+
return () => {
310+
const index = this.circuitStateHandlers.indexOf(handler);
311+
if (index !== -1) {
312+
this.circuitStateHandlers.splice(index, 1);
313+
}
314+
};
315+
}
316+
317+
/**
318+
* Reset the circuit breaker to closed state.
319+
*/
320+
resetCircuit(): void {
321+
this.clearCooldownTimer();
322+
this.consecutiveFailures = 0;
323+
this.setCircuitState('closed');
324+
}
325+
326+
/**
327+
* Get the current circuit breaker state.
328+
*/
329+
getCircuitState(): CircuitState {
330+
return this.circuitState;
331+
}
332+
256333
// =========================================================================
257334
// Statistics
258335
// =========================================================================
@@ -267,6 +344,7 @@ export class RetryQueue {
267344
failed: this.stats.failed,
268345
retries: this.stats.retries,
269346
paused: this.paused,
347+
circuitState: this.circuitState,
270348
};
271349
}
272350

@@ -307,6 +385,14 @@ export class RetryQueue {
307385
break;
308386
}
309387

388+
// Circuit breaker: fast-fail if open
389+
if (this.options.circuitBreaker && this.circuitState === 'open') {
390+
const item = this.queue.shift()!;
391+
this.stats.failed++;
392+
item.reject(NetworkError.circuitOpen());
393+
continue;
394+
}
395+
310396
// Check and wait for rate limit if needed
311397
const shouldContinue = await this.waitForRateLimit();
312398
if (shouldContinue) {
@@ -343,13 +429,21 @@ export class RetryQueue {
343429
this.stats.succeeded++;
344430
item.resolve(result);
345431

432+
if (this.options.circuitBreaker) {
433+
this.onOperationSuccess();
434+
}
435+
346436
// Record operation timestamp for rate limiting
347437
if (this.options.rateLimit) {
348438
this.recordOperation();
349439
}
350440
} catch (error) {
351441
item.attempts++;
352442

443+
if (this.options.circuitBreaker) {
444+
this.onOperationFailure();
445+
}
446+
353447
if (item.attempts <= this.options.maxRetries) {
354448
// Retry
355449
this.stats.retries++;
@@ -366,6 +460,59 @@ export class RetryQueue {
366460
}
367461
}
368462

463+
private onOperationSuccess(): void {
464+
this.consecutiveFailures = 0;
465+
if (this.circuitState === 'half-open') {
466+
this.setCircuitState('closed');
467+
}
468+
}
469+
470+
private onOperationFailure(): void {
471+
this.consecutiveFailures++;
472+
473+
if (this.circuitState === 'half-open') {
474+
this.setCircuitState('open');
475+
this.startCooldownTimer();
476+
return;
477+
}
478+
479+
const threshold = this.options.circuitBreaker!.threshold;
480+
if (this.consecutiveFailures >= threshold && this.circuitState === 'closed') {
481+
this.setCircuitState('open');
482+
this.startCooldownTimer();
483+
}
484+
}
485+
486+
private setCircuitState(newState: CircuitState): void {
487+
if (this.circuitState === newState) {
488+
return;
489+
}
490+
this.circuitState = newState;
491+
for (const handler of this.circuitStateHandlers) {
492+
try {
493+
handler(newState);
494+
} catch {
495+
// Ignore handler errors
496+
}
497+
}
498+
}
499+
500+
private startCooldownTimer(): void {
501+
this.clearCooldownTimer();
502+
const cooldownMs = this.options.circuitBreaker!.cooldownMs ?? 30000;
503+
this.circuitCooldownTimer = setTimeout(() => {
504+
this.setCircuitState('half-open');
505+
void this.processQueue();
506+
}, cooldownMs);
507+
}
508+
509+
private clearCooldownTimer(): void {
510+
if (this.circuitCooldownTimer !== null) {
511+
clearTimeout(this.circuitCooldownTimer);
512+
this.circuitCooldownTimer = null;
513+
}
514+
}
515+
369516
private calculateDelay(attempt: number): number {
370517
let delay: number;
371518

src/network/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
export { NetworkStatus } from './NetworkStatus.js';
22
export type { ConnectionType, NetworkInfo, NetworkInformation } from './NetworkStatus.js';
33
export { RetryQueue } from './RetryQueue.js';
4-
export type { RetryQueueOptions, RetryStats, BackoffStrategy } from './RetryQueue.js';
4+
export type {
5+
RetryQueueOptions,
6+
RetryStats,
7+
BackoffStrategy,
8+
CircuitState,
9+
CircuitBreakerOptions,
10+
} from './RetryQueue.js';

0 commit comments

Comments
 (0)