1- import { retryAwareRequest , isNetworkError , isTransientNetworkError } from './api.js'
1+ import { applyRetryJitterMs , retryAwareRequest , isNetworkError , isTransientNetworkError } from './api.js'
22import { recordRetry } from '../../public/node/analytics.js'
33import { ClientError } from 'graphql-request'
44import { describe , test , vi , expect , beforeEach , afterEach } from 'vitest'
@@ -7,13 +7,21 @@ vi.mock('../../public/node/analytics.js', () => ({
77 recordRetry : vi . fn ( ) ,
88} ) )
99
10+ // Pins Math.random so jitter multiplies the base delay by exactly 1.0 (the midpoint of the ±20% band).
11+ // Individual tests that need a different multiplier spy on Math.random directly.
12+ const MIDPOINT_RANDOM_VALUE = 0.5
13+
1014describe ( 'retryAwareRequest' , ( ) => {
15+ let randomSpy : ReturnType < typeof vi . spyOn >
16+
1117 beforeEach ( ( ) => {
1218 vi . useFakeTimers ( )
19+ randomSpy = vi . spyOn ( Math , 'random' ) . mockReturnValue ( MIDPOINT_RANDOM_VALUE )
1320 } )
1421
1522 afterEach ( ( ) => {
1623 vi . useRealTimers ( )
24+ randomSpy . mockRestore ( )
1725 } )
1826
1927 test ( 'handles retries' , async ( ) => {
@@ -88,7 +96,9 @@ describe('retryAwareRequest', () => {
8896
8997 expect ( mockRequestFn ) . toHaveBeenCalledTimes ( 4 )
9098 expect ( mockScheduleDelayFn ) . toHaveBeenCalledTimes ( 2 )
91- expect ( mockScheduleDelayFn ) . toHaveBeenNthCalledWith ( 1 , expect . anything ( ) , 200 )
99+ // Retry-After: 200 seconds -> 200_000 ms; jitter pinned to midpoint (1.0x).
100+ expect ( mockScheduleDelayFn ) . toHaveBeenNthCalledWith ( 1 , expect . anything ( ) , 200_000 )
101+ // defaultDelayMs: 500 ms; jitter pinned to midpoint (1.0x).
92102 expect ( mockScheduleDelayFn ) . toHaveBeenNthCalledWith ( 2 , expect . anything ( ) , 500 )
93103 } )
94104
@@ -602,6 +612,171 @@ describe('retryAwareRequest', () => {
602612 expect ( recordRetry ) . toHaveBeenCalledTimes ( 1 )
603613 expect ( recordRetry ) . toHaveBeenCalledWith ( 'https://themes.example.com/auth' , 'http-retry-1:can-retry:' )
604614 } )
615+
616+ test ( 'parses Retry-After header as seconds and converts to milliseconds' , async ( ) => {
617+ const rateLimitedResponse = {
618+ status : 200 ,
619+ errors : [
620+ {
621+ extensions : {
622+ code : '429' ,
623+ } ,
624+ } as any ,
625+ ] ,
626+ headers : new Headers ( { 'retry-after' : '2' } ) ,
627+ }
628+
629+ const successResponse = {
630+ status : 200 ,
631+ data : { ok : true } ,
632+ headers : new Headers ( ) ,
633+ }
634+
635+ const mockRequestFn = vi
636+ . fn ( )
637+ . mockImplementationOnce ( ( ) => {
638+ throw new ClientError ( rateLimitedResponse , { query : '' } )
639+ } )
640+ . mockImplementation ( ( ) => Promise . resolve ( successResponse ) )
641+
642+ const mockScheduleDelayFn = vi . fn ( ( fn ) => fn ( ) )
643+
644+ const result = retryAwareRequest (
645+ {
646+ request : mockRequestFn ,
647+ url : 'https://example.com' ,
648+ useNetworkLevelRetry : true ,
649+ maxRetryTimeMs : 10_000 ,
650+ } ,
651+ undefined ,
652+ { scheduleDelay : mockScheduleDelayFn } ,
653+ )
654+ await vi . runAllTimersAsync ( )
655+ await expect ( result ) . resolves . toEqual ( successResponse )
656+
657+ // Retry-After: 2 -> 2000 ms (not 2 ms). Jitter pinned to midpoint (1.0x) via Math.random mock.
658+ expect ( mockScheduleDelayFn ) . toHaveBeenCalledTimes ( 1 )
659+ expect ( mockScheduleDelayFn ) . toHaveBeenNthCalledWith ( 1 , expect . anything ( ) , 2000 )
660+ } )
661+
662+ test ( 'uses DEFAULT_RETRY_DELAY_MS when Retry-After header is absent and no caller default' , async ( ) => {
663+ const rateLimitedResponse = {
664+ status : 200 ,
665+ errors : [ { extensions : { code : '429' } } as any ] ,
666+ headers : new Headers ( ) ,
667+ }
668+
669+ const successResponse = {
670+ status : 200 ,
671+ data : { ok : true } ,
672+ headers : new Headers ( ) ,
673+ }
674+
675+ const mockRequestFn = vi
676+ . fn ( )
677+ . mockImplementationOnce ( ( ) => {
678+ throw new ClientError ( rateLimitedResponse , { query : '' } )
679+ } )
680+ . mockImplementation ( ( ) => Promise . resolve ( successResponse ) )
681+
682+ const mockScheduleDelayFn = vi . fn ( ( fn ) => fn ( ) )
683+
684+ const result = retryAwareRequest (
685+ {
686+ request : mockRequestFn ,
687+ url : 'https://example.com' ,
688+ useNetworkLevelRetry : true ,
689+ maxRetryTimeMs : 10_000 ,
690+ } ,
691+ undefined ,
692+ { scheduleDelay : mockScheduleDelayFn } ,
693+ )
694+ await vi . runAllTimersAsync ( )
695+ await expect ( result ) . resolves . toEqual ( successResponse )
696+
697+ // No Retry-After, no defaultDelayMs -> falls back to DEFAULT_RETRY_DELAY_MS (1000).
698+ // Jitter pinned to midpoint (1.0x) via Math.random mock.
699+ expect ( mockScheduleDelayFn ) . toHaveBeenCalledTimes ( 1 )
700+ expect ( mockScheduleDelayFn ) . toHaveBeenNthCalledWith ( 1 , expect . anything ( ) , 1000 )
701+ } )
702+
703+ test ( 'applies jitter multiplier from Math.random to retry delay' , async ( ) => {
704+ // Math.random() = 0.0 -> multiplier = 0.8 (lower bound).
705+ randomSpy . mockReturnValue ( 0 )
706+
707+ const rateLimitedResponse = {
708+ status : 200 ,
709+ errors : [ { extensions : { code : '429' } } as any ] ,
710+ headers : new Headers ( ) ,
711+ }
712+
713+ const successResponse = {
714+ status : 200 ,
715+ data : { ok : true } ,
716+ headers : new Headers ( ) ,
717+ }
718+
719+ const mockRequestFn = vi
720+ . fn ( )
721+ . mockImplementationOnce ( ( ) => {
722+ throw new ClientError ( rateLimitedResponse , { query : '' } )
723+ } )
724+ . mockImplementation ( ( ) => Promise . resolve ( successResponse ) )
725+
726+ const mockScheduleDelayFn = vi . fn ( ( fn ) => fn ( ) )
727+
728+ const result = retryAwareRequest (
729+ {
730+ request : mockRequestFn ,
731+ url : 'https://example.com' ,
732+ useNetworkLevelRetry : true ,
733+ maxRetryTimeMs : 10_000 ,
734+ } ,
735+ undefined ,
736+ { defaultDelayMs : 1000 , scheduleDelay : mockScheduleDelayFn } ,
737+ )
738+ await vi . runAllTimersAsync ( )
739+ await expect ( result ) . resolves . toEqual ( successResponse )
740+
741+ // 1000 * 0.8 = 800.
742+ expect ( mockScheduleDelayFn ) . toHaveBeenNthCalledWith ( 1 , expect . anything ( ) , 800 )
743+ } )
744+ } )
745+
746+ describe ( 'applyRetryJitterMs' , ( ) => {
747+ test ( 'returns the lower bound (0.8x) when random() is 0' , ( ) => {
748+ expect ( applyRetryJitterMs ( 1000 , ( ) => 0 ) ) . toBe ( 800 )
749+ } )
750+
751+ test ( 'returns the upper bound (~1.2x) when random() approaches 1' , ( ) => {
752+ // Math.random() returns [0, 1). Using 0.9999... exercises the top of the range.
753+ const nearOne = 1 - Number . EPSILON
754+ expect ( applyRetryJitterMs ( 1000 , ( ) => nearOne ) ) . toBeCloseTo ( 1200 , 5 )
755+ } )
756+
757+ test ( 'returns the midpoint (1.0x) when random() is 0.5' , ( ) => {
758+ expect ( applyRetryJitterMs ( 1000 , ( ) => 0.5 ) ) . toBe ( 1000 )
759+ } )
760+
761+ test ( 'keeps output within [0.8x, 1.2x] across the random range' , ( ) => {
762+ const baseDelayMs = 2500
763+ const samples = [ 0 , 0.1 , 0.25 , 0.5 , 0.75 , 0.9 , 0.9999 ]
764+ for ( const randomValue of samples ) {
765+ const jittered = applyRetryJitterMs ( baseDelayMs , ( ) => randomValue )
766+ expect ( jittered ) . toBeGreaterThanOrEqual ( baseDelayMs * 0.8 )
767+ expect ( jittered ) . toBeLessThanOrEqual ( baseDelayMs * 1.2 )
768+ }
769+ } )
770+
771+ test ( 'defaults to Math.random when no generator is passed' , ( ) => {
772+ const spy = vi . spyOn ( Math , 'random' ) . mockReturnValue ( 0.5 )
773+ try {
774+ expect ( applyRetryJitterMs ( 1000 ) ) . toBe ( 1000 )
775+ expect ( spy ) . toHaveBeenCalled ( )
776+ } finally {
777+ spy . mockRestore ( )
778+ }
779+ } )
605780} )
606781
607782describe ( 'isTransientNetworkError' , ( ) => {
0 commit comments