@@ -383,7 +383,7 @@ describe('fetchWithRetry', () => {
383383 vi . unstubAllGlobals ( ) ;
384384 } ) ;
385385
386- it ( 'should use exponential backoff delays capped at 10 seconds' , async ( ) => {
386+ function mockRetryDelays ( ) {
387387 const delays : number [ ] = [ ] ;
388388
389389 vi . spyOn ( globalThis , 'setTimeout' ) . mockImplementation ( ( ( fn : ( ) => void , delay ?: number ) => {
@@ -395,6 +395,104 @@ describe('fetchWithRetry', () => {
395395 return 0 as unknown as ReturnType < typeof setTimeout > ;
396396 } ) as typeof setTimeout ) ;
397397
398+ return delays ;
399+ }
400+
401+ it ( 'should return a successful response without retrying' , async ( ) => {
402+ const response = new Response ( 'ok' , { status : 200 } ) ;
403+ const mockFetch = vi . fn ( ) . mockResolvedValue ( response ) ;
404+ vi . stubGlobal ( 'fetch' , mockFetch ) ;
405+
406+ await expect ( fetchWithRetry ( 'https://example.com' , { method : 'POST' } , 3 ) ) . resolves . toBe ( response ) ;
407+
408+ expect ( mockFetch ) . toHaveBeenCalledTimes ( 1 ) ;
409+ expect ( mockFetch ) . toHaveBeenCalledWith ( 'https://example.com' , { method : 'POST' } ) ;
410+ } ) ;
411+
412+ it ( 'should retry a failed response and return a later success' , async ( ) => {
413+ const delays = mockRetryDelays ( ) ;
414+ const response = new Response ( 'ok' , { status : 200 } ) ;
415+ const mockFetch = vi
416+ . fn ( )
417+ . mockResolvedValueOnce ( new Response ( 'error' , { status : 500 , statusText : 'Server Error' } ) )
418+ . mockResolvedValueOnce ( response ) ;
419+ vi . stubGlobal ( 'fetch' , mockFetch ) ;
420+
421+ await expect ( fetchWithRetry ( 'https://example.com' , { } , 3 ) ) . resolves . toBe ( response ) ;
422+
423+ expect ( mockFetch ) . toHaveBeenCalledTimes ( 2 ) ;
424+ expect ( delays ) . toEqual ( [ 2000 ] ) ;
425+ } ) ;
426+
427+ it ( 'should retry network failures until retries are exhausted' , async ( ) => {
428+ const delays = mockRetryDelays ( ) ;
429+ const mockFetch = vi . fn ( ) . mockRejectedValue ( new Error ( 'Network error' ) ) ;
430+ vi . stubGlobal ( 'fetch' , mockFetch ) ;
431+
432+ await expect ( fetchWithRetry ( 'https://example.com' , { } , 3 ) ) . rejects . toThrow ( 'Network error' ) ;
433+
434+ expect ( mockFetch ) . toHaveBeenCalledTimes ( 3 ) ;
435+ expect ( delays ) . toEqual ( [ 2000 , 4000 ] ) ;
436+ } ) ;
437+
438+ it . each ( [ 404 , 408 , 429 ] ) ( 'should preserve public retry behavior for %s responses by default' , async status => {
439+ const delays = mockRetryDelays ( ) ;
440+ const mockFetch = vi . fn ( ) . mockResolvedValue ( new Response ( 'error' , { status } ) ) ;
441+ vi . stubGlobal ( 'fetch' , mockFetch ) ;
442+
443+ await expect ( fetchWithRetry ( 'https://example.com/missing' , { } , 2 ) ) . rejects . toThrow ( `status: ${ status } ` ) ;
444+
445+ expect ( mockFetch ) . toHaveBeenCalledTimes ( 2 ) ;
446+ expect ( delays ) . toEqual ( [ 2000 ] ) ;
447+ } ) ;
448+
449+ it ( 'should not retry a response when shouldRetryResponse returns false' , async ( ) => {
450+ const delays = mockRetryDelays ( ) ;
451+ const mockFetch = vi . fn ( ) . mockResolvedValue ( new Response ( 'not found' , { status : 404 , statusText : 'Not Found' } ) ) ;
452+ vi . stubGlobal ( 'fetch' , mockFetch ) ;
453+
454+ await expect (
455+ fetchWithRetry ( 'https://example.com/missing' , { } , 3 , {
456+ shouldRetryResponse : response => response . status >= 500 ,
457+ } ) ,
458+ ) . rejects . toThrow ( 'status: 404 Not Found' ) ;
459+
460+ expect ( mockFetch ) . toHaveBeenCalledTimes ( 1 ) ;
461+ expect ( delays ) . toEqual ( [ ] ) ;
462+ } ) ;
463+
464+ it ( 'should retry network errors even when the error message contains a 4xx status' , async ( ) => {
465+ const delays = mockRetryDelays ( ) ;
466+ const response = new Response ( 'ok' , { status : 200 } ) ;
467+ const mockFetch = vi . fn ( ) . mockRejectedValueOnce ( new Error ( 'upstream status: 404' ) ) . mockResolvedValueOnce ( response ) ;
468+ vi . stubGlobal ( 'fetch' , mockFetch ) ;
469+
470+ await expect (
471+ fetchWithRetry ( 'https://example.com/transient' , { } , 3 , {
472+ shouldRetryResponse : response => response . status >= 500 ,
473+ } ) ,
474+ ) . resolves . toBe ( response ) ;
475+
476+ expect ( mockFetch ) . toHaveBeenCalledTimes ( 2 ) ;
477+ expect ( delays ) . toEqual ( [ 2000 ] ) ;
478+ } ) ;
479+
480+ it ( 'should throw the last response error after exhausting retries' , async ( ) => {
481+ const delays = mockRetryDelays ( ) ;
482+ const mockFetch = vi
483+ . fn ( )
484+ . mockResolvedValueOnce ( new Response ( 'first' , { status : 500 , statusText : 'First Error' } ) )
485+ . mockResolvedValueOnce ( new Response ( 'second' , { status : 503 , statusText : 'Second Error' } ) ) ;
486+ vi . stubGlobal ( 'fetch' , mockFetch ) ;
487+
488+ await expect ( fetchWithRetry ( 'https://example.com/flaky' , { } , 2 ) ) . rejects . toThrow ( 'status: 503 Second Error' ) ;
489+
490+ expect ( mockFetch ) . toHaveBeenCalledTimes ( 2 ) ;
491+ expect ( delays ) . toEqual ( [ 2000 ] ) ;
492+ } ) ;
493+
494+ it ( 'should use exponential backoff delays capped at 10 seconds' , async ( ) => {
495+ const delays = mockRetryDelays ( ) ;
398496 const mockFetch = vi . fn ( ) . mockRejectedValue ( new Error ( 'Network error' ) ) ;
399497 vi . stubGlobal ( 'fetch' , mockFetch ) ;
400498
0 commit comments