feat: add attempt tracking, deadlineMs, and structured error codes

- Resolve `LookupResult` with an `attempts` array describing every network operation (phase, server, duration, error) so failures recovered by fallback remain visible
- Add `deadlineMs` option for a hard cap on total lookup time, distinct from per-operation `timeoutMs` (lowered default from 15 s to 10 s)
- Add `errorCode` (machine-readable `LookupErrorCode`), `errorPhase`, and `errorServer` to `LookupResult`; `timeout` covers both per-op and deadline timeouts, `aborted` means the caller's signal fired
- WHOIS timeouts now distinguish `connect` vs `read` stage and resolve with partial text (marked `partial: true`) when data arrived before the socket stalled
- Drop Node 18 support; minimum engine is now 20
This commit is contained in:
2026-09-18 14:41:16 -04:00
parent 87e20f4a47
commit 69f85ee925
24 changed files with 1394 additions and 254 deletions
+124
View File
@@ -0,0 +1,124 @@
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
class FakeSocket extends EventEmitter {
written: string[] = [];
destroyed = false;
write(data: string) {
this.written.push(data);
}
destroy() {
this.destroyed = true;
}
}
let socket: FakeSocket;
vi.mock("node:net", () => ({
createConnection: () => socket,
}));
import { whoisQuery } from "./client";
beforeEach(() => {
vi.useFakeTimers();
socket = new FakeSocket();
});
afterEach(() => {
vi.useRealTimers();
});
describe("whoisQuery timeouts", () => {
it("rejects with a connect-stage timeout when the socket never connects", async () => {
const p = whoisQuery("whois.example", "example.test", { timeoutMs: 1500 });
const assertion = expect(p).rejects.toMatchObject({
code: "timeout",
stage: "connect",
message: "WHOIS connect timeout (whois.example)",
});
await vi.advanceTimersByTimeAsync(1500);
await assertion;
expect(socket.destroyed).toBe(true);
});
it("rejects with a read-stage timeout when connected but silent", async () => {
const p = whoisQuery("whois.example", "example.test", { timeoutMs: 1500 });
const assertion = expect(p).rejects.toMatchObject({ code: "timeout", stage: "read" });
await vi.advanceTimersByTimeAsync(0);
socket.emit("connect");
expect(socket.written).toEqual(["example.test\r\n"]);
await vi.advanceTimersByTimeAsync(1500);
await assertion;
});
it("resolves with the partial text when a read times out after some data", async () => {
const p = whoisQuery("whois.example", "example.test", { timeoutMs: 1500 });
await vi.advanceTimersByTimeAsync(0);
socket.emit("connect");
socket.emit("data", Buffer.from("Domain Name: EXAMPLE.TEST\n"));
await vi.advanceTimersByTimeAsync(1500);
await expect(p).resolves.toEqual({
serverQueried: "whois.example",
text: "Domain Name: EXAMPLE.TEST\n",
partial: true,
});
expect(socket.destroyed).toBe(true);
});
it("does not corrupt multibyte characters split across chunks", async () => {
const p = whoisQuery("whois.example", "example.test");
await vi.advanceTimersByTimeAsync(0);
socket.emit("connect");
const bytes = Buffer.from("Registrant: Zoë");
socket.emit("data", bytes.subarray(0, bytes.length - 1));
socket.emit("data", bytes.subarray(bytes.length - 1));
socket.emit("end");
await expect(p).resolves.toMatchObject({ text: "Registrant: Zoë" });
});
it.each([500, 1000])("honours a %ims timeout exactly (no -1000ms adjustment)", async (ms) => {
const p = whoisQuery("whois.example", "example.test", { timeoutMs: ms });
const assertion = expect(p).rejects.toMatchObject({ stage: "connect" });
await vi.advanceTimersByTimeAsync(ms - 1);
expect(socket.destroyed).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await assertion;
});
it("does not throw for timeoutMs 0; the timeout is simply disabled", async () => {
const p = whoisQuery("whois.example", "example.test", { timeoutMs: 0 });
await vi.advanceTimersByTimeAsync(60_000);
expect(socket.destroyed).toBe(false);
socket.emit("connect");
socket.emit("data", Buffer.from("ok"));
socket.emit("end");
await expect(p).resolves.toMatchObject({ text: "ok" });
});
it("destroys the socket and rejects as aborted on signal abort", async () => {
const ctrl = new AbortController();
const p = whoisQuery("whois.example", "example.test", { signal: ctrl.signal });
await vi.advanceTimersByTimeAsync(0);
ctrl.abort();
await expect(p).rejects.toMatchObject({ code: "aborted" });
expect(socket.destroyed).toBe(true);
});
it("rejects immediately when the signal is already aborted", async () => {
const ctrl = new AbortController();
ctrl.abort();
await expect(
whoisQuery("whois.example", "example.test", { signal: ctrl.signal }),
).rejects.toMatchObject({ code: "aborted" });
});
it("keeps the data when a server resets the connection after replying", async () => {
const p = whoisQuery("whois.example", "example.test");
await vi.advanceTimersByTimeAsync(0);
socket.emit("connect");
socket.emit("data", Buffer.from("answer"));
socket.emit("error", Object.assign(new Error("reset"), { code: "ECONNRESET" }));
await expect(p).resolves.toMatchObject({ text: "answer", partial: true });
});
});
+72 -25
View File
@@ -1,10 +1,12 @@
import { withTimeout } from "../lib/async";
import { DEFAULT_TIMEOUT_MS } from "../lib/constants";
import { resolveTimeoutMs, throwIfAborted } from "../lib/async";
import { abortError, RdapperError } from "../lib/errors";
import type { LookupOptions } from "../types";
export interface WhoisQueryResult {
serverQueried: string;
text: string;
/** True when the read timed out after some data had arrived, so `text` may be truncated */
partial?: boolean;
}
/**
@@ -27,7 +29,6 @@ export async function whoisQuery(
query: string,
options?: LookupOptions,
): Promise<WhoisQueryResult> {
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const port = 43;
const host = server.replace(/^whois:\/\//i, "");
@@ -35,21 +36,19 @@ export async function whoisQuery(
const transformer = WHOIS_QUERY_TRANSFORMERS[host];
const transformedQuery = transformer ? transformer(query) : query;
const text = await withTimeout(
queryTcp(host, port, transformedQuery, options),
timeoutMs,
"WHOIS timeout",
);
return { serverQueried: server, text };
const { text, partial } = await queryTcp(host, port, transformedQuery, options);
return { serverQueried: server, text, ...(partial ? { partial } : {}) };
}
// Low-level WHOIS TCP client. Some registries require CRLF after the domain query.
// The socket code owns the timeout so it can tell a connect timeout from a read timeout,
// and can hand back whatever text arrived before a read timeout.
async function queryTcp(
host: string,
port: number,
query: string,
options?: LookupOptions,
): Promise<string> {
): Promise<{ text: string; partial?: boolean }> {
let net: typeof import("node:net") | null;
try {
net = await import("node:net");
@@ -58,36 +57,84 @@ async function queryTcp(
}
if (!net?.createConnection) {
throw new Error(
throw new RdapperError(
"unsupported_runtime",
"WHOIS client is only available in Node.js runtimes; try setting `rdapOnly: true`.",
);
}
const signal = options?.signal;
throwIfAborted(signal);
const timeoutMs = resolveTimeoutMs(options);
const createConnection = net.createConnection;
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port });
let data = "";
const socket = createConnection({ host, port });
const chunks: Buffer[] = [];
let received = 0;
let connected = false;
let done = false;
const cleanup = () => {
let timer: ReturnType<typeof setTimeout> | undefined;
const text = () => Buffer.concat(chunks).toString("utf8");
const finish = (settle: () => void) => {
if (done) return;
done = true;
if (timer !== undefined) clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
socket.destroy();
settle();
};
socket.setTimeout((options?.timeoutMs ?? DEFAULT_TIMEOUT_MS) - 1000, () => {
cleanup();
reject(new Error("WHOIS socket timeout"));
const onAbort = () => finish(() => reject(abortError(signal as AbortSignal)));
if (timeoutMs !== undefined) {
timer = setTimeout(() => {
if (!connected) {
finish(() =>
reject(
new RdapperError("timeout", `WHOIS connect timeout (${host})`, {
stage: "connect",
}),
),
);
} else if (received > 0) {
finish(() => resolve({ text: text(), partial: true }));
} else {
finish(() =>
reject(new RdapperError("timeout", `WHOIS read timeout (${host})`, { stage: "read" })),
);
}
}, timeoutMs);
}
signal?.addEventListener("abort", onAbort, { once: true });
socket.on("error", (err: NodeJS.ErrnoException) => {
// Servers that reset the connection after replying still gave us an answer
if (err.code === "ECONNRESET" && received > 0) {
finish(() => resolve({ text: text(), partial: true }));
} else {
finish(() => reject(err));
}
});
socket.on("error", (err) => {
cleanup();
reject(err);
});
socket.on("data", (chunk) => {
data += chunk.toString("utf8");
socket.on("data", (chunk: Buffer | string) => {
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
chunks.push(buf);
received += buf.length;
});
socket.on("end", () => {
cleanup();
resolve(data);
finish(() => resolve({ text: text() }));
});
// A close without a preceding end/error (half-open teardown) would otherwise wait out the timer
socket.on("close", () => {
if (connected) finish(() => resolve({ text: text() }));
else {
finish(() =>
reject(new RdapperError("connect_failed", `WHOIS connection closed (${host})`)),
);
}
});
socket.on("connect", () => {
connected = true;
socket.write(`${query}\r\n`);
});
});
+65 -23
View File
@@ -1,4 +1,7 @@
import type { LookupOptions } from "../types";
import { throwIfAborted } from "../lib/async";
import { classifyError } from "../lib/errors";
import { type LookupContext, traced } from "../lib/trace";
import type { LookupErrorCode, LookupOptions } from "../types";
import { whoisQuery } from "./client";
import { WHOIS_TLD_EXCEPTIONS } from "./servers";
@@ -43,14 +46,71 @@ export function parseIanaRegistrationInfoUrl(text: string): string | undefined {
return undefined;
}
const IANA_WHOIS_HOST = "whois.iana.org";
/** Result of {@link discoverWhoisServer}. */
export interface WhoisDiscovery {
/** Authoritative WHOIS server for the TLD, if one was found */
server?: string;
/** Raw IANA response, when IANA was queried successfully */
ianaText?: string;
/** Why the IANA query failed, when it did (timeouts and connection errors are otherwise silent) */
ianaFailure?: { code: LookupErrorCode; error: string };
}
/** Query IANA's WHOIS for a TLD, recording the attempt. Throws on failure. */
async function queryIana(
tld: string,
options?: LookupOptions,
ctx?: LookupContext,
): Promise<string> {
const res = await traced(ctx, { phase: "iana", server: IANA_WHOIS_HOST }, () =>
whoisQuery(IANA_WHOIS_HOST, tld.toLowerCase(), options),
);
return res.text;
}
/**
* Discover the authoritative WHOIS server for a TLD in a single IANA round trip, keeping the
* IANA text (for registration-info hints) and any failure (so callers can report it accurately).
* Caller aborts and deadlines are rethrown rather than swallowed.
*/
export async function discoverWhoisServer(
tld: string,
options?: LookupOptions,
ctx?: LookupContext,
): Promise<WhoisDiscovery> {
const key = tld.toLowerCase();
// 1) Explicit hint override
const hint = options?.whoisHints?.[key];
if (hint) return { server: normalizeServer(hint) };
// 2) IANA WHOIS authoritative discovery over TCP 43
const out: WhoisDiscovery = {};
try {
const text = await queryIana(key, options, ctx);
out.ianaText = text;
const server = parseIanaWhoisServer(text);
if (server) return { ...out, server: normalizeServer(server) };
} catch (err) {
throwIfAborted(options?.signal);
out.ianaFailure = classifyError(err);
}
// 3) Curated exceptions
const exception = WHOIS_TLD_EXCEPTIONS[key];
if (exception) return { ...out, server: normalizeServer(exception) };
return out;
}
/** Fetch raw IANA WHOIS text for a TLD (best-effort). */
export async function getIanaWhoisTextForTld(
tld: string,
options?: LookupOptions,
): Promise<string | undefined> {
try {
const res = await whoisQuery("whois.iana.org", tld.toLowerCase(), options);
return res.text;
return await queryIana(tld, options);
} catch {
return undefined;
}
@@ -62,27 +122,9 @@ export async function getIanaWhoisTextForTld(
export async function ianaWhoisServerForTld(
tld: string,
options?: LookupOptions,
ctx?: LookupContext,
): Promise<string | undefined> {
const key = tld.toLowerCase();
// 1) Explicit hint override
const hint = options?.whoisHints?.[key];
if (hint) return normalizeServer(hint);
// 2) IANA WHOIS authoritative discovery over TCP 43
try {
const res = await whoisQuery("whois.iana.org", key, options);
const txt = res.text;
const server = parseIanaWhoisServer(txt);
if (server) return normalizeServer(server);
} catch {
// fallthrough to exceptions/guess
}
// 3) Curated exceptions
const exception = WHOIS_TLD_EXCEPTIONS[key];
if (exception) return normalizeServer(exception);
return undefined;
return (await discoverWhoisServer(tld, options, ctx)).server;
}
/**
+30 -4
View File
@@ -1,3 +1,5 @@
import { throwIfAborted } from "../lib/async";
import { type LookupContext, traced } from "../lib/trace";
import type { LookupOptions } from "../types";
import type { WhoisQueryResult } from "./client";
import { whoisQuery } from "./client";
@@ -12,23 +14,25 @@ export async function followWhoisReferrals(
initialServer: string,
domain: string,
opts?: LookupOptions,
ctx?: LookupContext,
): Promise<WhoisQueryResult> {
const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
// First query against the provided server
let current = await whoisQuery(initialServer, domain, opts);
let current = await tracedWhoisQuery(initialServer, domain, opts, ctx);
if (opts?.followWhoisReferral === false || maxHops === 0) return current;
const visited = new Set<string>([normalize(current.serverQueried)]);
let hops = 0;
// Iterate while we see a new referral and are under hop limit
while (hops < maxHops) {
throwIfAborted(opts?.signal);
const next = extractWhoisReferral(current.text);
if (!next) break;
const normalized = normalize(next);
if (visited.has(normalized)) break; // cycle protection / same as current
visited.add(normalized);
try {
const res = await whoisQuery(next, domain, opts);
const res = await tracedWhoisQuery(next, domain, opts, ctx);
// Prefer authoritative TLD response when registrar contradicts availability
const registeredBefore = !isAvailableByWhois(current.text);
const registeredAfter = !isAvailableByWhois(res.text);
@@ -38,6 +42,7 @@ export async function followWhoisReferrals(
}
current = res; // adopt registrar when it does not downgrade registration
} catch {
throwIfAborted(opts?.signal);
// If referral server fails, stop following and keep the last good response
break;
}
@@ -55,10 +60,11 @@ export async function collectWhoisReferralChain(
initialServer: string,
domain: string,
opts?: LookupOptions,
ctx?: LookupContext,
): Promise<WhoisQueryResult[]> {
const results: WhoisQueryResult[] = [];
const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
const first = await whoisQuery(initialServer, domain, opts);
const first = await tracedWhoisQuery(initialServer, domain, opts, ctx);
results.push(first);
if (opts?.followWhoisReferral === false || maxHops === 0) return results;
@@ -66,13 +72,14 @@ export async function collectWhoisReferralChain(
let current = first;
let hops = 0;
while (hops < maxHops) {
throwIfAborted(opts?.signal);
const next = extractWhoisReferral(current.text);
if (!next) break;
const normalized = normalize(next);
if (visited.has(normalized)) break;
visited.add(normalized);
try {
const res = await whoisQuery(next, domain, opts);
const res = await tracedWhoisQuery(next, domain, opts, ctx);
// If registrar claims availability while TLD indicated registered, stop.
const registeredBefore = !isAvailableByWhois(current.text);
const registeredAfter = !isAvailableByWhois(res.text);
@@ -83,6 +90,7 @@ export async function collectWhoisReferralChain(
results.push(res);
current = res;
} catch {
throwIfAborted(opts?.signal);
break;
}
hops += 1;
@@ -93,3 +101,21 @@ export async function collectWhoisReferralChain(
function normalize(server: string): string {
return server.replace(/^whois:\/\//i, "").toLowerCase();
}
/** whoisQuery wrapped so each query (TLD or registrar hop) is recorded as an attempt. */
function tracedWhoisQuery(
server: string,
domain: string,
opts?: LookupOptions,
ctx?: LookupContext,
): Promise<WhoisQueryResult> {
return traced(
ctx,
{ phase: "whois", server: server.replace(/^whois:\/\//i, "") },
async (notes) => {
const res = await whoisQuery(server, domain, opts);
if (res.partial) notes.partial = true;
return res;
},
);
}