From cbe88d3dbacbe05e8e4787c92d26a0b5539cd49f Mon Sep 17 00:00:00 2001 From: Neil Daquioag <405533+ndycode@users.noreply.github.com> Date: Sat, 28 Feb 2026 07:47:39 +0800 Subject: [PATCH 1/3] fix(rate-limit): honor retry_after_ms units in response parsing --- lib/request/fetch-helpers.ts | 40 +++++++++++++++++++++++++----------- test/fetch-helpers.test.ts | 13 ++++++++++-- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index c004a531..09019bb9 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,10 +830,18 @@ 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"); @@ -881,18 +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 MAX_RETRY_DELAY_MS = 5 * 60 * 1000; return Math.min(ms, MAX_RETRY_DELAY_MS); } +function normalizeRetryAfterSeconds(value: number): number { + if (!Number.isFinite(value)) return 60000; + const ms = Math.floor(value * 1000); + const MAX_RETRY_DELAY_MS = 5 * 60 * 1000; + return Math.min(ms, MAX_RETRY_DELAY_MS); +} + function toNumber(value: unknown): number | undefined { if (value === null || value === undefined) return undefined; const parsed = Number(value); diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index 30b63984..0cf5d1d2 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,15 @@ 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('handles millisecond unix timestamp in reset header', async () => { const futureTimestampMs = Date.now() + 45000; const headers = new Headers({ 'x-ratelimit-reset': String(futureTimestampMs) }); From a0cc4f20b2bcbaf9d7bc2812445cd0c391d884c2 Mon Sep 17 00:00:00 2001 From: Neil Daquioag <405533+ndycode@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:00:27 +0800 Subject: [PATCH 2/3] fix(rate-limit): clamp delays and codify retry_after precedence --- lib/request/fetch-helpers.ts | 6 ++++-- test/fetch-helpers.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index 09019bb9..6729052b 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -898,15 +898,17 @@ function parseRetryAfterMs( function normalizeRetryAfterMilliseconds(value: number): number { if (!Number.isFinite(value)) return 60000; 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(ms, MAX_RETRY_DELAY_MS); + 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 0cf5d1d2..f03d83c1 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -700,6 +700,15 @@ describe('Fetch Helpers Module', () => { 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('handles millisecond unix timestamp in reset header', async () => { const futureTimestampMs = Date.now() + 45000; const headers = new Headers({ 'x-ratelimit-reset': String(futureTimestampMs) }); From 430969482ff747f0dc0513f3f108b3212a6ac4cb Mon Sep 17 00:00:00 2001 From: Neil Daquioag <405533+ndycode@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:00:59 +0800 Subject: [PATCH 3/3] fix(rate-limit): normalize header retry delays and add clamp boundary tests --- lib/request/fetch-helpers.ts | 4 ++-- test/fetch-helpers.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index 6729052b..faed5b9e 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -848,7 +848,7 @@ function parseRetryAfterMs( if (retryAfterMsHeader) { const parsed = Number.parseInt(retryAfterMsHeader, 10); if (!Number.isNaN(parsed) && parsed > 0) { - return parsed; + return normalizeRetryAfterMilliseconds(parsed); } } @@ -856,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); } } diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index f03d83c1..04cdeab1 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -709,6 +709,40 @@ describe('Fetch Helpers Module', () => { 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) });