@@ -34,6 +34,35 @@ function installRejectingAdapter(instance: AxiosInstance, status = 503): { count
3434 return { count : ( ) => calls } ;
3535}
3636
37+ // Installs an adapter that rejects with a synthetic 503 for the first
38+ // `failTimes` calls, then resolves with a success response, so we can prove a
39+ // retry actually recovers.
40+ function installRejectThenResolveAdapter (
41+ instance : AxiosInstance ,
42+ failTimes : number ,
43+ ) : { count : ( ) => number } {
44+ let calls = 0 ;
45+ instance . defaults . adapter = ( config : InternalAxiosRequestConfig ) => {
46+ calls += 1 ;
47+ if ( calls <= failTimes ) {
48+ const error = new Error ( 'synthetic failure' ) as AxiosError ;
49+ error . isAxiosError = true ;
50+ error . config = config ;
51+ error . toJSON = ( ) => ( { } ) ;
52+ error . response = { status : 503 , statusText : 'Error' , headers : { } , config, data : { } } ;
53+ return Promise . reject ( error ) ;
54+ }
55+ return Promise . resolve ( {
56+ status : 200 ,
57+ statusText : 'OK' ,
58+ headers : { } ,
59+ config,
60+ data : { ok : true } ,
61+ } ) ;
62+ } ;
63+ return { count : ( ) => calls } ;
64+ }
65+
3766async function newPermit ( overrides : Record < string , unknown > ) : Promise < PermitInternals > {
3867 const { Permit } = await import ( '../../index' ) ;
3968 return new Permit ( { token : 'test' , ...overrides } ) as unknown as PermitInternals ;
@@ -128,3 +157,66 @@ test.serial('maps axios-retry retryCount to our 0-based attempt number', async (
128157 global . setTimeout = realSetTimeout ;
129158 }
130159} ) ;
160+
161+ test ( 'a retry recovers: GET succeeds after one failed attempt' , async ( t ) => {
162+ const permit = await newPermit ( {
163+ retry : { maxRetries : 2 , retryDelay : 1 , maxDelay : 5 , backoffMultiplier : 1 } ,
164+ } ) ;
165+ const rest = installRejectThenResolveAdapter ( permit . config . axiosInstance , 1 ) ;
166+
167+ const response = await permit . config . axiosInstance . request ( { method : 'GET' , url : '/x' } ) ;
168+
169+ // Initial failure + one retry that succeeds.
170+ t . is ( rest . count ( ) , 2 ) ;
171+ t . is ( response . status , 200 ) ;
172+ t . deepEqual ( response . data , { ok : true } ) ;
173+ } ) ;
174+
175+ test . serial ( 'Retry-After header drives the retry delay end-to-end' , async ( t ) => {
176+ // 429 with `retry-after: 2` (seconds) must produce a 2000ms scheduled delay,
177+ // which exceeds the 10ms backoff and is under maxDelay, proving Retry-After
178+ // flows through calculateRetryDelay via our retryDelay callback. The
179+ // Retry-After branch returns the exact value with no jitter, so we only need
180+ // to capture the setTimeout delay (nothing is actually awaited).
181+ const realSetTimeout = global . setTimeout ;
182+ const scheduledDelays : number [ ] = [ ] ;
183+ global . setTimeout = ( ( fn : ( ) => void , delay ?: number ) : ReturnType < typeof setTimeout > => {
184+ scheduledDelays . push ( delay ?? 0 ) ;
185+ return realSetTimeout ( fn , 0 ) ;
186+ } ) as typeof setTimeout ;
187+ try {
188+ const permit = await newPermit ( {
189+ retry : { maxRetries : 1 , retryDelay : 10 , maxDelay : 30000 , backoffMultiplier : 1 } ,
190+ } ) ;
191+
192+ let calls = 0 ;
193+ permit . config . axiosInstance . defaults . adapter = (
194+ config : InternalAxiosRequestConfig ,
195+ ) : Promise < never > => {
196+ calls += 1 ;
197+ const error = new Error ( 'rate limited' ) as AxiosError ;
198+ error . isAxiosError = true ;
199+ error . config = config ;
200+ error . toJSON = ( ) => ( { } ) ;
201+ error . response = {
202+ status : 429 ,
203+ statusText : 'Too Many Requests' ,
204+ headers : { 'retry-after' : '2' } ,
205+ config,
206+ data : { } ,
207+ } ;
208+ return Promise . reject ( error ) ;
209+ } ;
210+
211+ await t . throwsAsync ( ( ) => permit . config . axiosInstance . request ( { method : 'GET' , url : '/x' } ) ) ;
212+
213+ // 429 is retryable and GET is allowed, so it retried once.
214+ t . is ( calls , 2 ) ;
215+ t . true (
216+ scheduledDelays . includes ( 2000 ) ,
217+ `expected a 2000ms delay, got ${ scheduledDelays . join ( ',' ) } ` ,
218+ ) ;
219+ } finally {
220+ global . setTimeout = realSetTimeout ;
221+ }
222+ } ) ;
0 commit comments