From ec6849191aafe30d3353b87620d7773b23aae0a4 Mon Sep 17 00:00:00 2001 From: PINYO PATTANAWASANPORN Date: Sun, 4 Oct 2026 21:38:45 +0700 Subject: [PATCH 1/2] fix(api): parse Retry-After HTTP-date format and remove dead fallback code (#572) Add parseRetryAfter helper to compute seconds from both integer seconds and HTTP-date strings per RFC 9110, and clean up dead-code fallback expression. --- src/lib/api.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 18374b0..21fea52 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -95,6 +95,22 @@ async function dedupedFetch( 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 @@ -132,14 +148,14 @@ export async function apiRequest( // --- 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, ); } From d7ce354a77fa62f45ac444271119276ac183f52d Mon Sep 17 00:00:00 2001 From: PINYO PATTANAWASANPORN Date: Sun, 4 Oct 2026 21:38:47 +0700 Subject: [PATCH 2/2] fix(api): parse Retry-After HTTP-date format and remove dead fallback code (#572) Add parseRetryAfter helper to compute seconds from both integer seconds and HTTP-date strings per RFC 9110, and clean up dead-code fallback expression. --- src/lib/api.test.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 743b86d..0c4eb83 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -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.", + ); + }); +}); +