mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-23 01:25:31 -04:00
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:
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user