From 93f877c3571aee0f781fb89be4d8c5ca57e057b0 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sat, 19 Sep 2026 12:30:06 -0400 Subject: [PATCH] fix: extend Retry-After parsing to RDAP 503 and fix block pattern across newlines - Parse `Retry-After` on RDAP `503` responses (previously only `429`), exposing `retryAfterMs` on the attempt and on the top-level result when `rdapOnly` makes it terminal - Fix the WHOIS `BLOCK_PATTERNS` regex to use `[\s\S]` instead of `.` so a block notice that spans multiple lines (e.g. `"Your IP address\nhas been blocked"`) is still classified as `blocked` - Update README to document `retryAfterMs` on `LookupResult`, clarify `rate_limited` / `blocked` / `unparseable` semantics, and note referral-host validation behaviour --- README.md | 11 ++++++++++- src/index.attempts.test.ts | 13 +++++++++++++ src/rdap/client.ts | 8 +++++++- src/whois/throttle.test.ts | 1 + src/whois/throttle.ts | 2 +- 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0cd53ab..779acc2 100644 --- a/README.md +++ b/README.md @@ -449,11 +449,20 @@ interface LookupResult { errorCode?: LookupErrorCode; // machine-readable, present when ok is false errorPhase?: "rdap_bootstrap" | "rdap" | "rdap_link" | "iana" | "whois"; errorServer?: string; // RDAP URL or WHOIS host involved in the failure + retryAfterMs?: number; // server's Retry-After, when it was the terminal failure (see below) attempts: LookupAttempt[]; // every network operation, in order (always present) } ``` -`errorCode` is one of `invalid_input`, `invalid_tld`, `timeout`, `aborted`, `connect_failed`, `http_error`, `rdap_unavailable`, `no_server`, `no_data`, `unsupported_runtime`, or `unknown`. Prefer it over matching `error` text. `timeout` covers every timeout, including `deadlineMs`; `aborted` means your own `signal` fired. +`errorCode` is one of `invalid_input`, `invalid_tld`, `timeout`, `aborted`, `connect_failed`, `http_error`, `rdap_unavailable`, `no_server`, `no_data`, `rate_limited`, `blocked`, `unparseable`, `unsupported_runtime`, or `unknown`. Prefer it over matching `error` text. `timeout` covers every timeout, including `deadlineMs`; `aborted` means your own `signal` fired. + +- `rate_limited`: the server throttled the query (RDAP `429`, or a short WHOIS notice such as `WHOIS LIMIT EXCEEDED`). Retrying later may work. +- `blocked`: a WHOIS server refuses this client outright (e.g. `.ch`: "Requests of this client are not permitted"). Retrying will not help. +- `unparseable`: WHOIS replied with text that is neither an availability notice nor a domain record (no registrar, dates, nameservers, statuses or contacts). It is reported as a failure rather than a "registered" record. + +`retryAfterMs` carries an RDAP `Retry-After` header (on `429` or `503`, as seconds or an HTTP date). It is set on the matching entry in `attempts`, and on the top-level result only when that RDAP attempt is the terminal failure, as with `rdapOnly`. Normally an RDAP failure falls through to WHOIS, so the value stays in `attempts`. The value is passed through as sent and is not capped, so clamp it before using it as a delay. + +WHOIS referral hosts (from `Registrar WHOIS Server:` and similar fields) come from upstream response text, so they are validated (hostname syntax, no private/loopback/link-local IP literals) and their resolved address is checked at connect time. An unsafe, blocked or throttled referral is skipped with an entry in `record.warnings`. The first WHOIS server, from IANA or `whoisHints`, is trusted and not checked. Each entry in `attempts` describes one operation, successful or not, so a failure that was recovered from (say, an RDAP server that was down before WHOIS answered) is still visible: diff --git a/src/index.attempts.test.ts b/src/index.attempts.test.ts index 04e6559..b70eccd 100644 --- a/src/index.attempts.test.ts +++ b/src/index.attempts.test.ts @@ -91,6 +91,19 @@ describe("throttle and empty-response guards", () => { }); describe("rdapOnly rate limiting", () => { + it("parses Retry-After on a 503", async () => { + const customFetch: FetchLike = vi.fn( + async () => new Response("", { status: 503, headers: { "retry-after": "5" } }), + ); + const res = await lookup("example.com", { + customBootstrapData: bootstrap, + customFetch, + rdapOnly: true, + }); + expect(res.errorCode).toBe("rdap_unavailable"); + expect(res.retryAfterMs).toBe(5_000); + }); + it("surfaces retryAfterMs on the result", async () => { const customFetch: FetchLike = vi.fn( async () => new Response("", { status: 429, headers: { "retry-after": "12" } }), diff --git a/src/rdap/client.ts b/src/rdap/client.ts index 9c59446..cbee502 100644 --- a/src/rdap/client.ts +++ b/src/rdap/client.ts @@ -64,7 +64,13 @@ export async function fetchRdapDomain( } if (!res.ok) { const bodyText = await res.text().catch(() => ""); - throw new RdapperError("http_error", `RDAP ${res.status}: ${bodyText.slice(0, 500)}`); + const retryAfterMs = + res.status === 503 ? parseRetryAfterMs(res.headers.get("retry-after")) : undefined; + throw new RdapperError( + "http_error", + `RDAP ${res.status}: ${bodyText.slice(0, 500)}`, + retryAfterMs !== undefined ? { retryAfterMs } : undefined, + ); } const json = await res.json(); return { url, json }; diff --git a/src/whois/throttle.test.ts b/src/whois/throttle.test.ts index d09bae7..33b698a 100644 --- a/src/whois/throttle.test.ts +++ b/src/whois/throttle.test.ts @@ -15,6 +15,7 @@ describe("detectWhoisRefusal", () => { it.each([ "Requests of this client are not permitted. Please use https://www.nic.ch/whois/ for queries.", "Your IP address has been blocked", + "Your IP address\nhas been blocked", ])("classifies %j as blocked", (text) => { expect(detectWhoisRefusal(text)).toBe("blocked"); }); diff --git a/src/whois/throttle.ts b/src/whois/throttle.ts index c123f42..e4b31e7 100644 --- a/src/whois/throttle.ts +++ b/src/whois/throttle.ts @@ -17,7 +17,7 @@ const THROTTLE_PATTERNS: RegExp[] = [ // Permanent: this client is refused outright, so retrying will not help. const BLOCK_PATTERNS: RegExp[] = [ /requests\s+of\s+this\s+client\s+are\s+not\s+permitted/i, // .ch/.li - /\b(your|this)\s+(ip|address|client)\b.{0,60}\b(blocked|banned|blacklisted|not\s+permitted)\b/i, + /\b(your|this)\s+(ip|address|client)\b[\s\S]{0,60}\b(blocked|banned|blacklisted|not\s+permitted)\b/i, ]; /**