@@ -641,6 +641,166 @@ describe("paths.mjs exports", () => {
641641 expect ( elapsed ) . toBeLessThan ( 150 )
642642 } )
643643
644+ describe ( "error recovery integration" , ( ) => {
645+ it ( "should return immediately on first attempt success without any retries" , async ( ) => {
646+ const attemptTimestamps : number [ ] = [ ]
647+ const start = Date . now ( )
648+
649+ const result = await retryOnTransientError (
650+ ( ) => {
651+ attemptTimestamps . push ( Date . now ( ) - start )
652+ return { data : "immediate success" , timestamp : Date . now ( ) }
653+ } ,
654+ { retries : 5 , initialDelayMs : 100 } ,
655+ )
656+
657+ // Verify single attempt
658+ expect ( attemptTimestamps ) . toHaveLength ( 1 )
659+ // Verify result is returned correctly
660+ expect ( result . data ) . toBe ( "immediate success" )
661+ // Verify no delay was incurred (should be nearly instant)
662+ expect ( attemptTimestamps [ 0 ] ) . toBeLessThan ( 50 )
663+ } )
664+
665+ it ( "should respect exact retry count: retries=1 means 2 total attempts" , async ( ) => {
666+ const attempts : number [ ] = [ ]
667+
668+ await expect (
669+ retryOnTransientError (
670+ ( ) => {
671+ attempts . push ( attempts . length + 1 )
672+ throw Object . assign ( new Error ( "EAGAIN" ) , { code : "EAGAIN" } )
673+ } ,
674+ { retries : 1 , initialDelayMs : 1 } ,
675+ ) ,
676+ ) . rejects . toThrow ( "EAGAIN" )
677+
678+ // retries=1 means: 1 initial attempt + 1 retry = 2 total
679+ expect ( attempts ) . toEqual ( [ 1 , 2 ] )
680+ } )
681+
682+ it ( "should respect exact retry count: retries=4 means 5 total attempts" , async ( ) => {
683+ const attempts : number [ ] = [ ]
684+
685+ await expect (
686+ retryOnTransientError (
687+ ( ) => {
688+ attempts . push ( attempts . length + 1 )
689+ throw Object . assign ( new Error ( "EBUSY" ) , { code : "EBUSY" } )
690+ } ,
691+ { retries : 4 , initialDelayMs : 1 } ,
692+ ) ,
693+ ) . rejects . toThrow ( "EBUSY" )
694+
695+ // retries=4 means: 1 initial attempt + 4 retries = 5 total
696+ expect ( attempts ) . toEqual ( [ 1 , 2 , 3 , 4 , 5 ] )
697+ } )
698+
699+ it ( "should throw non-transient error immediately without any retry attempts" , async ( ) => {
700+ const errorCodes = [ "ENOENT" , "EACCES" , "EPERM" , "ENOSPC" , "EROFS" ]
701+
702+ for ( const code of errorCodes ) {
703+ const attempts : number [ ] = [ ]
704+
705+ await expect (
706+ retryOnTransientError (
707+ ( ) => {
708+ attempts . push ( attempts . length + 1 )
709+ throw Object . assign ( new Error ( code ) , { code } )
710+ } ,
711+ { retries : 10 , initialDelayMs : 1 } ,
712+ ) ,
713+ ) . rejects . toThrow ( code )
714+
715+ // Non-transient errors should fail immediately with only 1 attempt
716+ expect ( attempts ) . toEqual ( [ 1 ] )
717+ }
718+ } )
719+
720+ it ( "should return the exact result value after successful retry" , async ( ) => {
721+ let attemptCount = 0
722+ const expectedResult = {
723+ nested : { value : 42 , array : [ 1 , 2 , 3 ] } ,
724+ status : "recovered" ,
725+ }
726+
727+ const result = await retryOnTransientError (
728+ ( ) => {
729+ attemptCount ++
730+ if ( attemptCount < 3 ) {
731+ throw Object . assign ( new Error ( "EAGAIN" ) , { code : "EAGAIN" } )
732+ }
733+ return expectedResult
734+ } ,
735+ { retries : 5 , initialDelayMs : 1 } ,
736+ )
737+
738+ // Verify the exact object is returned
739+ expect ( result ) . toEqual ( expectedResult )
740+ expect ( result . nested . value ) . toBe ( 42 )
741+ expect ( result . nested . array ) . toEqual ( [ 1 , 2 , 3 ] )
742+ expect ( result . status ) . toBe ( "recovered" )
743+ // Verify it took exactly 3 attempts
744+ expect ( attemptCount ) . toBe ( 3 )
745+ } )
746+
747+ it ( "should preserve error details when throwing after exhausting retries" , async ( ) => {
748+ const customError = Object . assign ( new Error ( "Resource busy: /tmp/file.lock" ) , {
749+ code : "EBUSY" ,
750+ path : "/tmp/file.lock" ,
751+ syscall : "open" ,
752+ } )
753+
754+ try {
755+ await retryOnTransientError (
756+ ( ) => {
757+ throw customError
758+ } ,
759+ { retries : 2 , initialDelayMs : 1 } ,
760+ )
761+ expect . unreachable ( "Should have thrown" )
762+ } catch ( err ) {
763+ // Verify the exact same error object is thrown
764+ expect ( err ) . toBe ( customError )
765+ expect ( ( err as NodeJS . ErrnoException ) . code ) . toBe ( "EBUSY" )
766+ expect ( ( err as NodeJS . ErrnoException ) . path ) . toBe ( "/tmp/file.lock" )
767+ expect ( ( err as NodeJS . ErrnoException ) . syscall ) . toBe ( "open" )
768+ }
769+ } )
770+
771+ it ( "should track state correctly across retry attempts" , async ( ) => {
772+ const stateLog : Array < { attempt : number ; timestamp : number } > = [ ]
773+ const start = Date . now ( )
774+
775+ const result = await retryOnTransientError (
776+ ( ) => {
777+ const attempt = stateLog . length + 1
778+ stateLog . push ( { attempt, timestamp : Date . now ( ) - start } )
779+
780+ if ( attempt < 4 ) {
781+ throw Object . assign ( new Error ( "EAGAIN" ) , { code : "EAGAIN" } )
782+ }
783+ return `success on attempt ${ attempt } `
784+ } ,
785+ { retries : 5 , initialDelayMs : 10 } ,
786+ )
787+
788+ // Verify correct number of attempts
789+ expect ( stateLog ) . toHaveLength ( 4 )
790+ expect ( stateLog . map ( ( s ) => s . attempt ) ) . toEqual ( [ 1 , 2 , 3 , 4 ] )
791+
792+ // Verify result reflects final successful attempt
793+ expect ( result ) . toBe ( "success on attempt 4" )
794+
795+ // Verify delays occurred between attempts (exponential backoff)
796+ // Attempt 1: ~0ms, Attempt 2: ~10ms, Attempt 3: ~30ms, Attempt 4: ~70ms
797+ expect ( stateLog [ 0 ] ?. timestamp ) . toBeLessThan ( 10 )
798+ expect ( stateLog [ 1 ] ?. timestamp ) . toBeGreaterThanOrEqual ( 5 )
799+ expect ( stateLog [ 2 ] ?. timestamp ) . toBeGreaterThanOrEqual ( 20 )
800+ expect ( stateLog [ 3 ] ?. timestamp ) . toBeGreaterThanOrEqual ( 50 )
801+ } )
802+ } )
803+
644804 describe ( "input validation" , ( ) => {
645805 it ( "should throw TypeError when fn is null" , async ( ) => {
646806 await expect ( retryOnTransientError ( null as unknown as ( ) => void ) ) . rejects . toThrow (
0 commit comments