diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index c004a531..faed5b9e 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -691,15 +691,21 @@ interface RateLimitErrorBody { function parseRateLimitBody( body: string, -): { code?: string; resetsAt?: number; retryAfterMs?: number } | undefined { +): { + code?: string; + resetsAt?: number; + retryAfterMs?: number; + retryAfterSeconds?: number; +} | undefined { if (!body) return undefined; try { const parsed = JSON.parse(body) as RateLimitErrorBody; const error = parsed?.error ?? {}; const code = (error.code ?? error.type ?? "").toString(); const resetsAt = toNumber(error.resets_at ?? error.reset_at); - const retryAfterMs = toNumber(error.retry_after_ms ?? error.retry_after); - return { code, resetsAt, retryAfterMs }; + const retryAfterMs = toNumber(error.retry_after_ms); + const retryAfterSeconds = toNumber(error.retry_after); + return { code, resetsAt, retryAfterMs, retryAfterSeconds }; } catch { return undefined; } @@ -824,17 +830,25 @@ function ensureJsonErrorResponse(response: Response, payload: ErrorPayload): Res function parseRetryAfterMs( response: Response, - parsedBody?: { resetsAt?: number; retryAfterMs?: number }, + parsedBody?: { + resetsAt?: number; + retryAfterMs?: number; + retryAfterSeconds?: number; + }, ): number | null { if (parsedBody?.retryAfterMs !== undefined) { - return normalizeRetryAfter(parsedBody.retryAfterMs); + return normalizeRetryAfterMilliseconds(parsedBody.retryAfterMs); + } + + if (parsedBody?.retryAfterSeconds !== undefined) { + return normalizeRetryAfterSeconds(parsedBody.retryAfterSeconds); } const retryAfterMsHeader = response.headers.get("retry-after-ms"); if (retryAfterMsHeader) { const parsed = Number.parseInt(retryAfterMsHeader, 10); if (!Number.isNaN(parsed) && parsed > 0) { - return parsed; + return normalizeRetryAfterMilliseconds(parsed); } } @@ -842,7 +856,7 @@ function parseRetryAfterMs( if (retryAfterHeader) { const parsed = Number.parseInt(retryAfterHeader, 10); if (!Number.isNaN(parsed) && parsed > 0) { - return parsed * 1000; + return normalizeRetryAfterSeconds(parsed); } } @@ -881,16 +895,20 @@ function parseRetryAfterMs( return null; } -function normalizeRetryAfter(value: number): number { +function normalizeRetryAfterMilliseconds(value: number): number { if (!Number.isFinite(value)) return 60000; - let ms: number; - if (value > 0 && value < 1000) { - ms = Math.floor(value * 1000); - } else { - ms = Math.floor(value); - } + const ms = Math.floor(value); + const MIN_RETRY_DELAY_MS = 1; const MAX_RETRY_DELAY_MS = 5 * 60 * 1000; - return Math.min(ms, MAX_RETRY_DELAY_MS); + return Math.min(Math.max(ms, MIN_RETRY_DELAY_MS), MAX_RETRY_DELAY_MS); +} + +function normalizeRetryAfterSeconds(value: number): number { + if (!Number.isFinite(value)) return 60000; + const ms = Math.floor(value * 1000); + const MIN_RETRY_DELAY_MS = 1; + const MAX_RETRY_DELAY_MS = 5 * 60 * 1000; + return Math.min(Math.max(ms, MIN_RETRY_DELAY_MS), MAX_RETRY_DELAY_MS); } function toNumber(value: unknown): number | undefined { diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index 30b63984..04cdeab1 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -664,13 +664,13 @@ describe('Fetch Helpers Module', () => { expect(rateLimit?.retryAfterMs).toBeGreaterThan(0); }); - it('normalizes small retryAfterMs values as seconds', async () => { + it('keeps retry_after_ms values in milliseconds even when small', async () => { const body = { error: { message: 'rate limited', retry_after_ms: 5 } }; const response = new Response(JSON.stringify(body), { status: 429 }); const { rateLimit } = await handleErrorResponse(response); - expect(rateLimit?.retryAfterMs).toBe(5000); + expect(rateLimit?.retryAfterMs).toBe(5); }); it('caps retryAfterMs at 5 minutes', async () => { @@ -691,6 +691,58 @@ describe('Fetch Helpers Module', () => { expect(rateLimit?.retryAfterMs).toBe(60000); }); + it('treats retry_after as seconds from body payload', async () => { + const body = { error: { message: 'rate limited', retry_after: 5 } }; + const response = new Response(JSON.stringify(body), { status: 429 }); + + const { rateLimit } = await handleErrorResponse(response); + + expect(rateLimit?.retryAfterMs).toBe(5000); + }); + + it('prefers retry_after_ms over retry_after when both are present', async () => { + const body = { error: { message: 'rate limited', retry_after_ms: 250, retry_after: 5 } }; + const response = new Response(JSON.stringify(body), { status: 429 }); + + const { rateLimit } = await handleErrorResponse(response); + + expect(rateLimit?.retryAfterMs).toBe(250); + }); + + it('clamps retry_after_ms zero and negative values to minimum delay', async () => { + const zeroResponse = new Response( + JSON.stringify({ error: { message: 'rate limited', retry_after_ms: 0 } }), + { status: 429 }, + ); + const negativeResponse = new Response( + JSON.stringify({ error: { message: 'rate limited', retry_after_ms: -5 } }), + { status: 429 }, + ); + + const zeroRateLimit = await handleErrorResponse(zeroResponse); + const negativeRateLimit = await handleErrorResponse(negativeResponse); + + expect(zeroRateLimit.rateLimit?.retryAfterMs).toBe(1); + expect(negativeRateLimit.rateLimit?.retryAfterMs).toBe(1); + }); + + it('clamps retry_after zero and negative values to minimum delay', async () => { + const zeroResponse = new Response( + JSON.stringify({ error: { message: 'rate limited', retry_after: 0 } }), + { status: 429 }, + ); + const negativeResponse = new Response( + JSON.stringify({ error: { message: 'rate limited', retry_after: -5 } }), + { status: 429 }, + ); + + const zeroRateLimit = await handleErrorResponse(zeroResponse); + const negativeRateLimit = await handleErrorResponse(negativeResponse); + + expect(zeroRateLimit.rateLimit?.retryAfterMs).toBe(1); + expect(negativeRateLimit.rateLimit?.retryAfterMs).toBe(1); + }); + it('handles millisecond unix timestamp in reset header', async () => { const futureTimestampMs = Date.now() + 45000; const headers = new Headers({ 'x-ratelimit-reset': String(futureTimestampMs) });