87 lines
3.3 KiB
TypeScript
87 lines
3.3 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { handleNonOkResponse, initialRetryState } from '../src/retry-policy.js';
|
|
|
|
// R3-003: A 429 response can carry Retry-After as an HTTP-date
|
|
// (RFC 7231 §7.1.3) instead of delta-seconds. `parseInt` on the date string
|
|
// yields NaN, which propagates to waitMs (NaN * 1000 = NaN, Math.min(NaN, cap)
|
|
// = NaN). The agent loop then calls `setTimeout(r, NaN)`, which fires
|
|
// immediately — hammering the endpoint and burning the retry budget.
|
|
|
|
function rateLimitedResponse(retryAfter: string | null): Response {
|
|
const headers = new Headers();
|
|
if (retryAfter !== null) headers.set('retry-after', retryAfter);
|
|
return new Response('rate limited', { status: 429, headers });
|
|
}
|
|
|
|
describe('handleNonOkResponse — 429 Retry-After parsing (R3-003)', () => {
|
|
it('does not produce a NaN wait when Retry-After is an HTTP-date', async () => {
|
|
// A future HTTP-date should resolve to a finite, positive wait
|
|
// (seconds until that date), capped at MAX_RATE_LIMIT_WAIT_MS (60s).
|
|
const futureDate = new Date(Date.now() + 30_000).toUTCString();
|
|
const action = await handleNonOkResponse(
|
|
rateLimitedResponse(futureDate),
|
|
initialRetryState(),
|
|
);
|
|
|
|
expect(action.kind).toBe('retry');
|
|
if (action.kind !== 'retry') throw new Error('expected retry');
|
|
|
|
// The core bug: waitMs must be a finite number, never NaN.
|
|
expect(Number.isFinite(action.waitMs)).toBe(true);
|
|
// ~30s out, allowing a little slack for clock/rounding.
|
|
expect(action.waitMs).toBeGreaterThan(25_000);
|
|
expect(action.waitMs).toBeLessThanOrEqual(60_000);
|
|
// And the human-readable notice must not say "waiting NaNs".
|
|
expect(action.notice).not.toContain('NaN');
|
|
});
|
|
|
|
it('clamps a past HTTP-date to a finite non-negative wait', async () => {
|
|
const action = await handleNonOkResponse(
|
|
rateLimitedResponse('Wed, 21 Oct 2020 07:28:00 GMT'),
|
|
initialRetryState(),
|
|
);
|
|
|
|
expect(action.kind).toBe('retry');
|
|
if (action.kind !== 'retry') throw new Error('expected retry');
|
|
// Past date → retry now (0ms), but crucially finite — not NaN.
|
|
expect(Number.isFinite(action.waitMs)).toBe(true);
|
|
expect(action.waitMs).toBeGreaterThanOrEqual(0);
|
|
expect(action.notice).not.toContain('NaN');
|
|
});
|
|
|
|
it('falls back to the sane default for a non-numeric Retry-After', async () => {
|
|
const action = await handleNonOkResponse(
|
|
rateLimitedResponse('garbage'),
|
|
initialRetryState(),
|
|
);
|
|
|
|
expect(action.kind).toBe('retry');
|
|
if (action.kind !== 'retry') throw new Error('expected retry');
|
|
|
|
// Default backoff is 5s.
|
|
expect(action.waitMs).toBe(5_000);
|
|
});
|
|
|
|
it('still honours a plain numeric (delta-seconds) Retry-After', async () => {
|
|
const action = await handleNonOkResponse(
|
|
rateLimitedResponse('12'),
|
|
initialRetryState(),
|
|
);
|
|
|
|
expect(action.kind).toBe('retry');
|
|
if (action.kind !== 'retry') throw new Error('expected retry');
|
|
expect(action.waitMs).toBe(12_000);
|
|
});
|
|
|
|
it('still uses the default when Retry-After is absent', async () => {
|
|
const action = await handleNonOkResponse(
|
|
rateLimitedResponse(null),
|
|
initialRetryState(),
|
|
);
|
|
|
|
expect(action.kind).toBe('retry');
|
|
if (action.kind !== 'retry') throw new Error('expected retry');
|
|
expect(action.waitMs).toBe(5_000);
|
|
});
|
|
});
|