Skip to content

Commit e12e28b

Browse files
zeevmoneyclaude
andcommitted
Add success-after-retry and Retry-After end-to-end tests
Close the coverage gap from the axios-retry migration review: - a retryable GET that fails once then succeeds resolves (proves a retry actually recovers, not just exhausts); - a 429 with `retry-after: 2` schedules a 2000ms delay, proving the Retry-After header flows through calculateRetryDelay via the axios-retry retryDelay callback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e1d3d30 commit e12e28b

1 file changed

Lines changed: 92 additions & 0 deletions

File tree

src/tests/unit/retry-interceptor.spec.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
3766
async 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

Comments
 (0)