Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
42 changes: 30 additions & 12 deletions src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Comment thread
aeitwoen marked this conversation as resolved.
}

return 60;
return Math.max(fallbackDelay, retryAfterDelay);
}

/** HTTP client with retry, backoff, rate limiting, and timeout. */
Expand Down Expand Up @@ -58,8 +76,8 @@ 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;
return retryDelayFor(context.response?.headers.get("Retry-After"), jitteredDelay);
},
retryStatusCodes: [408, 409, 425, 429, 500, 502, 503, 504],
timeout: this.timeout,
Expand Down
43 changes: 43 additions & 0 deletions test/e2e/client.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
});
14 changes: 13 additions & 1 deletion test/unit/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseRetryAfter } from "../../src/core/client.ts";
import { parseRetryAfter, retryDelayFor } from "../../src/core/client.ts";
import { RateLimitError } from "../../src/core/errors.ts";

describe("parseRetryAfter", () => {
it("parses numeric seconds", () => {
Expand Down Expand Up @@ -60,3 +61,14 @@ describe("parseRetryAfter", () => {
expect(parseRetryAfter(" ")).toBe(60);
});
});

describe("retryDelayFor", () => {
it("falls back for invalid values", () => {
expect(retryDelayFor("not-a-delay", 75)).toBe(75);
Comment thread
aeitwoen marked this conversation as resolved.
});

it("rejects values above the timer limit", () => {
expect(retryDelayFor("2147483", 10)).toBe(2_147_483_000);
expect(() => retryDelayFor("2147484", 10)).toThrow(RateLimitError);
});
});
Loading