Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions lib/request/fetch-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 20 additions & 2 deletions test/fetch-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -691,6 +691,24 @@ 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('handles millisecond unix timestamp in reset header', async () => {
const futureTimestampMs = Date.now() + 45000;
const headers = new Headers({ 'x-ratelimit-reset': String(futureTimestampMs) });
Expand Down