@@ -13,7 +13,10 @@ import {
1313 abortable ,
1414 createDeferred ,
1515 waterfall ,
16- rateLimit
16+ rateLimit ,
17+ circuitBreaker ,
18+ CircuitBreakerOpenError ,
19+ runWithConcurrency
1720} from '../../src/utils/async.utils' ;
1821
1922describe ( 'sleep' , ( ) => {
@@ -351,3 +354,121 @@ describe('rateLimit', () => {
351354 jest . useRealTimers ( ) ;
352355 } ) ;
353356} ) ;
357+
358+ describe ( 'circuitBreaker' , ( ) => {
359+ it ( 'allows calls when closed and resets failures on success' , async ( ) => {
360+ let count = 0 ;
361+ const fn = async ( ) => ++ count ;
362+ const breaker = circuitBreaker ( fn , { failureThreshold : 2 , resetTimeout : 50 } ) ;
363+ expect ( await breaker ( ) ) . toBe ( 1 ) ;
364+ expect ( await breaker ( ) ) . toBe ( 2 ) ;
365+ } ) ;
366+
367+ it ( 'opens circuit after failures and blocks calls' , async ( ) => {
368+ let fail = true ;
369+ const fn = async ( ) => {
370+ if ( fail ) throw new Error ( 'fail' ) ;
371+ return 'ok' ;
372+ } ;
373+ let opened = false ;
374+ const breaker = circuitBreaker ( fn , {
375+ failureThreshold : 2 ,
376+ resetTimeout : 50 ,
377+ onOpen : ( ) => {
378+ opened = true ;
379+ }
380+ } ) ;
381+ await expect ( breaker ( ) ) . rejects . toThrow ( 'fail' ) ;
382+ await expect ( breaker ( ) ) . rejects . toThrow ( 'fail' ) ;
383+ expect ( opened ) . toBe ( true ) ;
384+ await expect ( breaker ( ) ) . rejects . toBeInstanceOf ( CircuitBreakerOpenError ) ;
385+ } ) ;
386+
387+ it ( 'moves to half-open after resetTimeout and closes on success' , async ( ) => {
388+ let fail = true ;
389+ let halfOpen = false ,
390+ closed = false ;
391+ const fn = async ( ) => {
392+ if ( fail ) throw new Error ( 'fail' ) ;
393+ return 'ok' ;
394+ } ;
395+ const breaker = circuitBreaker ( fn , {
396+ failureThreshold : 1 ,
397+ resetTimeout : 10 ,
398+ successThreshold : 1 ,
399+ onHalfOpen : ( ) => {
400+ halfOpen = true ;
401+ } ,
402+ onClose : ( ) => {
403+ closed = true ;
404+ }
405+ } ) ;
406+ await expect ( breaker ( ) ) . rejects . toThrow ( 'fail' ) ;
407+ await expect ( breaker ( ) ) . rejects . toBeInstanceOf ( CircuitBreakerOpenError ) ;
408+ await new Promise ( res => setTimeout ( res , 12 ) ) ;
409+ fail = false ;
410+ expect ( await breaker ( ) ) . toBe ( 'ok' ) ;
411+ expect ( halfOpen ) . toBe ( true ) ;
412+ expect ( closed ) . toBe ( true ) ;
413+ } ) ;
414+
415+ it ( 'throws CircuitBreakerOpenError when open' , async ( ) => {
416+ const fn = async ( ) => {
417+ throw new Error ( 'fail' ) ;
418+ } ;
419+ const breaker = circuitBreaker ( fn , { failureThreshold : 1 , resetTimeout : 100 } ) ;
420+ await expect ( breaker ( ) ) . rejects . toThrow ( 'fail' ) ;
421+ await expect ( breaker ( ) ) . rejects . toBeInstanceOf ( CircuitBreakerOpenError ) ;
422+ } ) ;
423+ } ) ;
424+
425+ describe ( 'runWithConcurrency' , ( ) => {
426+ it ( 'runs tasks with concurrency' , async ( ) => {
427+ const order : number [ ] = [ ] ;
428+ const tasks = [ 1 , 2 , 3 , 4 , 5 ] . map ( n => async ( ) => {
429+ await sleep ( 10 ) ;
430+ order . push ( n ) ;
431+ return n ;
432+ } ) ;
433+ const result = await runWithConcurrency ( tasks , { concurrency : 2 } ) ;
434+ expect ( result . sort ( ) ) . toEqual ( [ 1 , 2 , 3 , 4 , 5 ] ) ;
435+ expect ( order . length ) . toBe ( 5 ) ;
436+ } ) ;
437+
438+ it ( 'calls onProgress callback' , async ( ) => {
439+ const progress : number [ ] = [ ] ;
440+ const tasks = [ 1 , 2 , 3 ] . map ( n => async ( ) => n ) ;
441+ await runWithConcurrency ( tasks , {
442+ concurrency : 2 ,
443+ onProgress : ( completed , _total ) => progress . push ( completed )
444+ } ) ;
445+ expect ( progress ) . toEqual ( [ 1 , 2 , 3 ] ) ;
446+ } ) ;
447+
448+ it ( 'aborts if signal is triggered' , async ( ) => {
449+ const ctrl = new AbortController ( ) ;
450+ const tasks = [
451+ async ( ) => {
452+ await sleep ( 10 ) ;
453+ return 1 ;
454+ } ,
455+ async ( ) => {
456+ await sleep ( 10 ) ;
457+ return 2 ;
458+ }
459+ ] ;
460+ setTimeout ( ( ) => ctrl . abort ( ) , 5 ) ;
461+ await expect ( runWithConcurrency ( tasks , { concurrency : 1 , signal : ctrl . signal } ) ) . rejects . toThrow ( 'Aborted' ) ;
462+ } ) ;
463+
464+ it ( 'returns empty array for no tasks' , async ( ) => {
465+ const result = await runWithConcurrency ( [ ] , { concurrency : 2 } ) ;
466+ expect ( result ) . toEqual ( [ ] ) ;
467+ } ) ;
468+
469+ it ( 'throws if already aborted' , async ( ) => {
470+ const ctrl = new AbortController ( ) ;
471+ ctrl . abort ( ) ;
472+ await expect ( runWithConcurrency ( [ async ( ) => 1 ] , { signal : ctrl . signal } ) ) . rejects . toThrow ( 'Aborted' ) ;
473+ } ) ;
474+ } ) ;
0 commit comments