Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
55 changes: 55 additions & 0 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,58 @@ describe("fetchIndexableReputationHandles", () => {
expect(result.data).toEqual(["mock-handle"]);
});
});

describe("parseRetryAfter", () => {
it("parses integer seconds correctly", () => {
const { parseRetryAfter } = require("./api");
expect(parseRetryAfter("120")).toBe(120);
expect(parseRetryAfter("0")).toBe(0);
expect(parseRetryAfter(" 45 ")).toBe(45);
expect(parseRetryAfter("-10")).toBeUndefined();
expect(parseRetryAfter("invalid")).toBeUndefined();
});

it("parses HTTP-date headers into remaining seconds", () => {
const { parseRetryAfter } = require("./api");
const futureDate = new Date(Date.now() + 60_000).toUTCString();
const seconds = parseRetryAfter(futureDate);
expect(seconds).toBeGreaterThanOrEqual(58);
expect(seconds).toBeLessThanOrEqual(61);
});
});

describe("apiRequest rate limiting (429)", () => {
const originalFetch = global.fetch;

afterAll(() => {
global.fetch = originalFetch;
});

it("formats 429 error message correctly with integer Retry-After", async () => {
const { apiRequest, ApiRequestError } = require("./api");
global.fetch = jest.fn().mockResolvedValue({
status: 429,
ok: false,
headers: new Headers({ "Retry-After": "30" }),
}) as unknown as typeof fetch;

await expect(apiRequest("/test-endpoint")).rejects.toThrow(ApiRequestError);
await expect(apiRequest("/test-endpoint")).rejects.toThrow(
"You're doing that too fast. Please wait 30 seconds before trying again.",
);
});

it("formats 429 error message correctly without Retry-After header", async () => {
const { apiRequest } = require("./api");
global.fetch = jest.fn().mockResolvedValue({
status: 429,
ok: false,
headers: new Headers(),
}) as unknown as typeof fetch;

await expect(apiRequest("/test-endpoint-no-header")).rejects.toThrow(
"You're doing that too fast.",
);
});
});

24 changes: 20 additions & 4 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,22 @@ async function dedupedFetch<T>(
return promise;
}

export function parseRetryAfter(header: string): number | undefined {
const trimmed = header.trim();
if (/^\d+$/.test(trimmed)) {
const sec = parseInt(trimmed, 10);
return Number.isFinite(sec) && sec >= 0 ? sec : undefined;
}
if (!trimmed.includes("GMT") && !trimmed.includes(",")) {
return undefined;
}
const dateMs = Date.parse(trimmed);
if (!Number.isNaN(dateMs)) {
return Math.max(0, Math.ceil((dateMs - Date.now()) / 1000));
}
return undefined;
}

/**
* Client-side call that attaches the signed-in user's JWT (if any) and
* surfaces backend error bodies instead of silently falling back — used for
Expand Down Expand Up @@ -132,14 +148,14 @@ export async function apiRequest<T>(
// --- Rate-limit handling (#44) ---
if (res.status === 429) {
const retryAfter = res.headers.get("Retry-After");
const seconds = retryAfter ? parseInt(retryAfter, 10) : NaN;
const waitMsg = Number.isFinite(seconds)
const seconds = retryAfter ? parseRetryAfter(retryAfter) : undefined;
const waitMsg = seconds !== undefined
? ` Please wait ${seconds} second${seconds === 1 ? "" : "s"} before trying again.`
: "";
throw new ApiRequestError(
`You're doing that too fast.${waitMsg}` || `Rate limited. Please try again later.`,
`You're doing that too fast.${waitMsg}`,
429,
Number.isFinite(seconds) ? seconds : undefined,
seconds,
);
}

Expand Down