From 8346934aabdacdc5674c41ae323c31b8fe47e30e Mon Sep 17 00:00:00 2001 From: aeitwoen Date: Sat, 15 Aug 2026 16:11:12 +0200 Subject: [PATCH 1/6] fix(client): respect Retry-After before retrying --- src/core/client.ts | 7 +++++-- test/unit/client.test.ts | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/core/client.ts b/src/core/client.ts index b66fe10..67991a0 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -58,8 +58,11 @@ export class Client { const remaining = typeof context.options.retry === "number" ? context.options.retry : 0; const attempt = maxRetries - remaining; const delay = baseDelay * Math.pow(2, attempt - 1); - const jitter = delay * Math.random() * 0.1; - return delay + jitter; + const jitteredDelay = delay + delay * Math.random() * 0.1; + const retryAfter = context.response?.headers.get("Retry-After"); + return retryAfter === null || retryAfter === undefined + ? jitteredDelay + : Math.max(jitteredDelay, parseRetryAfter(retryAfter) * 1000); }, retryStatusCodes: [408, 409, 425, 429, 500, 502, 503, 504], timeout: this.timeout, diff --git a/test/unit/client.test.ts b/test/unit/client.test.ts index 44c8073..14c8dc5 100644 --- a/test/unit/client.test.ts +++ b/test/unit/client.test.ts @@ -1,4 +1,5 @@ -import { parseRetryAfter } from "../../src/core/client.ts"; +import { createServer } from "node:http"; +import { Client, parseRetryAfter } from "../../src/core/client.ts"; describe("parseRetryAfter", () => { it("parses numeric seconds", () => { @@ -60,3 +61,39 @@ describe("parseRetryAfter", () => { expect(parseRetryAfter(" ")).toBe(60); }); }); + +describe("Client", () => { + it("waits for Retry-After before retrying a 429 response", async () => { + let requests = 0; + const server = createServer((_request, response) => { + requests++; + if (requests === 1) { + response.writeHead(429, { Connection: "close", "Retry-After": "1" }); + response.end(); + return; + } + + response.writeHead(200, { Connection: "close", "Content-Type": "application/json" }); + response.end('{"ok":true}'); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP server address"); + + const startedAt = performance.now(); + await new Client({ maxRetries: 1, baseDelay: 10 }).getJSON( + `http://127.0.0.1:${address.port}`, + ); + + expect(performance.now() - startedAt).toBeGreaterThanOrEqual(900); + expect(requests).toBe(2); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); +}); From 5356a15e3cb104bc7cbdae544ed1a6d7abb45080 Mon Sep 17 00:00:00 2001 From: aeitwoen Date: Sat, 15 Aug 2026 16:27:31 +0200 Subject: [PATCH 2/6] fix(client): bound Retry-After delay --- src/core/client.ts | 43 +++++++++++++++++++++++++++------------- test/unit/client.test.ts | 29 +++++++++++++++++++++------ 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/src/core/client.ts b/src/core/client.ts index 67991a0..b731a1a 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -7,6 +7,24 @@ const DEFAULT_MAX_RETRIES = 5; const DEFAULT_BASE_DELAY = 50; const DEFAULT_TIMEOUT = 30_000; const DEFAULT_USER_AGENT = "registries/0.1.0"; +const MAX_TIMER_DELAY = 2_147_483_647; + +function parseRetryAfterValue(header: string | null | undefined): number | undefined { + if (!header) return undefined; + const trimmed = header.trim(); + if (!trimmed) return undefined; + + if (/^\d+$/.test(trimmed)) { + const seconds = Number(trimmed); + return Number.isNaN(seconds) ? undefined : seconds; + } + + const timestamp = /[a-z]/i.test(trimmed) ? Date.parse(trimmed) : NaN; + if (Number.isNaN(timestamp)) return undefined; + + const seconds = Math.ceil((timestamp - Date.now()) / 1000); + return Math.max(seconds, 0); +} /** * Parse a `Retry-After` header into seconds. @@ -18,19 +36,19 @@ const DEFAULT_USER_AGENT = "registries/0.1.0"; * Returns 60 when the header is absent, empty, or unparseable. */ export function parseRetryAfter(header: string | null | undefined): number { - if (!header) return 60; - const trimmed = header.trim(); - if (!trimmed) return 60; + return parseRetryAfterValue(header) ?? 60; +} - if (/^\d+$/.test(trimmed)) return Number(trimmed); +/** Apply a valid, timer-safe `Retry-After` value or reject an unschedulable delay. */ +export function retryDelayFor(header: string | null | undefined, fallbackDelay: number): number { + const retryAfter = parseRetryAfterValue(header); + if (retryAfter === undefined) return fallbackDelay; - const timestamp = /[a-z]/i.test(trimmed) ? Date.parse(trimmed) : NaN; - if (!Number.isNaN(timestamp)) { - const seconds = Math.ceil((timestamp - Date.now()) / 1000); - return Math.max(seconds, 0); + const retryAfterDelay = retryAfter * 1000; + if (!Number.isFinite(retryAfterDelay) || retryAfterDelay > MAX_TIMER_DELAY) { + throw new RateLimitError(retryAfter); } - - return 60; + return Math.max(fallbackDelay, retryAfterDelay); } /** HTTP client with retry, backoff, rate limiting, and timeout. */ @@ -59,10 +77,7 @@ export class Client { const attempt = maxRetries - remaining; const delay = baseDelay * Math.pow(2, attempt - 1); const jitteredDelay = delay + delay * Math.random() * 0.1; - const retryAfter = context.response?.headers.get("Retry-After"); - return retryAfter === null || retryAfter === undefined - ? jitteredDelay - : Math.max(jitteredDelay, parseRetryAfter(retryAfter) * 1000); + return retryDelayFor(context.response?.headers.get("Retry-After"), jitteredDelay); }, retryStatusCodes: [408, 409, 425, 429, 500, 502, 503, 504], timeout: this.timeout, diff --git a/test/unit/client.test.ts b/test/unit/client.test.ts index 14c8dc5..ae2c1b1 100644 --- a/test/unit/client.test.ts +++ b/test/unit/client.test.ts @@ -1,5 +1,6 @@ import { createServer } from "node:http"; -import { Client, parseRetryAfter } from "../../src/core/client.ts"; +import { Client, parseRetryAfter, retryDelayFor } from "../../src/core/client.ts"; +import { RateLimitError } from "../../src/core/errors.ts"; describe("parseRetryAfter", () => { it("parses numeric seconds", () => { @@ -62,6 +63,17 @@ describe("parseRetryAfter", () => { }); }); +describe("retryDelayFor", () => { + it("falls back for invalid values", () => { + expect(retryDelayFor("not-a-delay", 75)).toBe(75); + }); + + it("rejects values above the timer limit", () => { + expect(retryDelayFor("2147483", 10)).toBe(2_147_483_000); + expect(() => retryDelayFor("2147484", 10)).toThrow(RateLimitError); + }); +}); + describe("Client", () => { it("waits for Retry-After before retrying a 429 response", async () => { let requests = 0; @@ -77,17 +89,22 @@ describe("Client", () => { response.end('{"ok":true}'); }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); try { const address = server.address(); if (!address || typeof address === "string") throw new Error("Expected TCP server address"); + const url = `http://127.0.0.1:${address.port}`; const startedAt = performance.now(); - await new Client({ maxRetries: 1, baseDelay: 10 }).getJSON( - `http://127.0.0.1:${address.port}`, - ); - + await new Client({ maxRetries: 1, baseDelay: 10 }).getJSON(url); expect(performance.now() - startedAt).toBeGreaterThanOrEqual(900); expect(requests).toBe(2); } finally { From 22687a143b4cf7d69cd2fd3c331e4b11717bb898 Mon Sep 17 00:00:00 2001 From: aeitwoen Date: Sat, 15 Aug 2026 16:32:01 +0200 Subject: [PATCH 3/6] test(client): move live retry check to e2e --- test/e2e/client.test.ts | 43 +++++++++++++++++++++++++++++++++++++++ test/unit/client.test.ts | 44 +--------------------------------------- 2 files changed, 44 insertions(+), 43 deletions(-) create mode 100644 test/e2e/client.test.ts diff --git a/test/e2e/client.test.ts b/test/e2e/client.test.ts new file mode 100644 index 0000000..64a8593 --- /dev/null +++ b/test/e2e/client.test.ts @@ -0,0 +1,43 @@ +import { createServer } from "node:http"; +import { Client } from "../../src/core/client.ts"; + +describe("Client", () => { + it("waits for Retry-After before retrying a 429 response", async () => { + let requests = 0; + const server = createServer((_request, response) => { + requests++; + if (requests === 1) { + response.writeHead(429, { Connection: "close", "Retry-After": "1" }); + response.end(); + return; + } + + response.writeHead(200, { Connection: "close", "Content-Type": "application/json" }); + response.end('{"ok":true}'); + }); + + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP server address"); + const url = `http://127.0.0.1:${address.port}`; + + const startedAt = performance.now(); + await new Client({ maxRetries: 1, baseDelay: 10 }).getJSON(url); + expect(performance.now() - startedAt).toBeGreaterThanOrEqual(900); + expect(requests).toBe(2); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); +}); diff --git a/test/unit/client.test.ts b/test/unit/client.test.ts index ae2c1b1..4cc91a0 100644 --- a/test/unit/client.test.ts +++ b/test/unit/client.test.ts @@ -1,5 +1,4 @@ -import { createServer } from "node:http"; -import { Client, parseRetryAfter, retryDelayFor } from "../../src/core/client.ts"; +import { parseRetryAfter, retryDelayFor } from "../../src/core/client.ts"; import { RateLimitError } from "../../src/core/errors.ts"; describe("parseRetryAfter", () => { @@ -73,44 +72,3 @@ describe("retryDelayFor", () => { expect(() => retryDelayFor("2147484", 10)).toThrow(RateLimitError); }); }); - -describe("Client", () => { - it("waits for Retry-After before retrying a 429 response", async () => { - let requests = 0; - const server = createServer((_request, response) => { - requests++; - if (requests === 1) { - response.writeHead(429, { Connection: "close", "Retry-After": "1" }); - response.end(); - return; - } - - response.writeHead(200, { Connection: "close", "Content-Type": "application/json" }); - response.end('{"ok":true}'); - }); - - await new Promise((resolve, reject) => { - const onError = (error: Error) => reject(error); - server.once("error", onError); - server.listen(0, "127.0.0.1", () => { - server.off("error", onError); - resolve(); - }); - }); - - try { - const address = server.address(); - if (!address || typeof address === "string") throw new Error("Expected TCP server address"); - const url = `http://127.0.0.1:${address.port}`; - - const startedAt = performance.now(); - await new Client({ maxRetries: 1, baseDelay: 10 }).getJSON(url); - expect(performance.now() - startedAt).toBeGreaterThanOrEqual(900); - expect(requests).toBe(2); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } - }); -}); From 714b319f8c94db11dbadd3efece7d6fe9ae68608 Mon Sep 17 00:00:00 2001 From: aeitwoen Date: Sat, 15 Aug 2026 17:10:43 +0200 Subject: [PATCH 4/6] fix(client): preserve retry error types --- src/core/client.ts | 10 +++++++++- test/e2e/client.test.ts | 20 +++++++++++++++++--- test/unit/client.test.ts | 4 ++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/core/client.ts b/src/core/client.ts index b731a1a..565cd3c 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -77,7 +77,15 @@ export class Client { const attempt = maxRetries - remaining; const delay = baseDelay * Math.pow(2, attempt - 1); const jitteredDelay = delay + delay * Math.random() * 0.1; - return retryDelayFor(context.response?.headers.get("Retry-After"), jitteredDelay); + const retryAfter = context.response?.headers.get("Retry-After"); + try { + return retryDelayFor(retryAfter, jitteredDelay); + } catch (error) { + if (error instanceof RateLimitError && context.response?.status !== 429) { + throw new HTTPError(context.response?.status ?? 0, String(context.request), ""); + } + throw error; + } }, retryStatusCodes: [408, 409, 425, 429, 500, 502, 503, 504], timeout: this.timeout, diff --git a/test/e2e/client.test.ts b/test/e2e/client.test.ts index 64a8593..44e0cc5 100644 --- a/test/e2e/client.test.ts +++ b/test/e2e/client.test.ts @@ -1,13 +1,18 @@ import { createServer } from "node:http"; import { Client } from "../../src/core/client.ts"; +import { HTTPError } from "../../src/core/errors.ts"; describe("Client", () => { - it("waits for Retry-After before retrying a 429 response", async () => { + it("honors Retry-After without misclassifying server errors", async () => { let requests = 0; + let responseStatus: number | null = 429; + let retryAfter = "1"; const server = createServer((_request, response) => { requests++; - if (requests === 1) { - response.writeHead(429, { Connection: "close", "Retry-After": "1" }); + if (responseStatus !== null) { + const status = responseStatus; + responseStatus = null; + response.writeHead(status, { Connection: "close", "Retry-After": retryAfter }); response.end(); return; } @@ -34,6 +39,15 @@ describe("Client", () => { await new Client({ maxRetries: 1, baseDelay: 10 }).getJSON(url); expect(performance.now() - startedAt).toBeGreaterThanOrEqual(900); expect(requests).toBe(2); + + responseStatus = 503; + retryAfter = "2147484"; + const failure = new Client({ maxRetries: 1, baseDelay: 10 }).getJSON(url); + await expect(failure).rejects.toMatchObject({ + name: HTTPError.name, + statusCode: 503, + }); + expect(requests).toBe(3); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); diff --git a/test/unit/client.test.ts b/test/unit/client.test.ts index 4cc91a0..a8144c0 100644 --- a/test/unit/client.test.ts +++ b/test/unit/client.test.ts @@ -67,6 +67,10 @@ describe("retryDelayFor", () => { expect(retryDelayFor("not-a-delay", 75)).toBe(75); }); + it("keeps a longer local backoff", () => { + expect(retryDelayFor("0", 75)).toBe(75); + }); + it("rejects values above the timer limit", () => { expect(retryDelayFor("2147483", 10)).toBe(2_147_483_000); expect(() => retryDelayFor("2147484", 10)).toThrow(RateLimitError); From f7f83c767a1b3d48e26ccf1fc5a862fcf93bed29 Mon Sep 17 00:00:00 2001 From: aeitwoen Date: Sat, 15 Aug 2026 18:18:11 +0200 Subject: [PATCH 5/6] test(client): clarify timer boundary --- test/unit/client.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/unit/client.test.ts b/test/unit/client.test.ts index a8144c0..9e1c22e 100644 --- a/test/unit/client.test.ts +++ b/test/unit/client.test.ts @@ -72,6 +72,7 @@ describe("retryDelayFor", () => { }); it("rejects values above the timer limit", () => { + // Node timers accept at most 2_147_483_647 ms, so these straddle that ceiling in seconds. expect(retryDelayFor("2147483", 10)).toBe(2_147_483_000); expect(() => retryDelayFor("2147484", 10)).toThrow(RateLimitError); }); From 821c1a18b54afbeec05c19d1b4d2c728747c4d3d Mon Sep 17 00:00:00 2001 From: aeitwoen Date: Sat, 15 Aug 2026 18:22:22 +0200 Subject: [PATCH 6/6] test(client): explain e2e timer boundary --- test/e2e/client.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/client.test.ts b/test/e2e/client.test.ts index 44e0cc5..f61f000 100644 --- a/test/e2e/client.test.ts +++ b/test/e2e/client.test.ts @@ -41,6 +41,7 @@ describe("Client", () => { expect(requests).toBe(2); responseStatus = 503; + // 2_147_484 seconds converts to 2_147_484_000 ms, just above Node's timer ceiling. retryAfter = "2147484"; const failure = new Client({ maxRetries: 1, baseDelay: 10 }).getJSON(url); await expect(failure).rejects.toMatchObject({