@@ -55,6 +55,18 @@ import { NetworkStatus } from './NetworkStatus.js';
5555
5656export 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+
5870export 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
120139interface QueuedOperation < T > {
@@ -125,11 +144,12 @@ interface QueuedOperation<T> {
125144}
126145
127146export 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
0 commit comments