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
+94
View File
@@ -0,0 +1,94 @@
import type { LookupAttempt, LookupErrorCode } from "../types";
/**
* Error thrown by rdapper internals with a machine-readable code.
* Aborts are named "AbortError" so callers that sniff `err.name` keep working.
*/
export class RdapperError extends Error {
readonly code: LookupErrorCode;
readonly phase?: LookupAttempt["phase"];
readonly server?: string;
readonly stage?: "connect" | "read";
constructor(
code: LookupErrorCode,
message: string,
extra?: {
phase?: LookupAttempt["phase"];
server?: string;
stage?: "connect" | "read";
cause?: unknown;
},
) {
super(message, extra?.cause !== undefined ? { cause: extra.cause } : undefined);
this.name = code === "aborted" ? "AbortError" : "RdapperError";
this.code = code;
this.phase = extra?.phase;
this.server = extra?.server;
this.stage = extra?.stage;
}
}
/**
* The error to surface for an aborted signal. A deadline (or any other internal abort)
* carries its own RdapperError as the reason; anything else is a caller abort.
*/
export function abortError(signal: AbortSignal): RdapperError {
const reason: unknown = signal.reason;
if (reason instanceof RdapperError) return reason;
return new RdapperError("aborted", "Lookup aborted", { cause: reason });
}
const CONNECT_ERRNOS = new Set([
"ECONNREFUSED",
"ECONNRESET",
"ENOTFOUND",
"EHOSTUNREACH",
"ENETUNREACH",
"ENETDOWN",
"EAI_AGAIN",
"EPIPE",
"EPERM",
"EACCES",
]);
function errnoOf(err: unknown): string | undefined {
if (typeof err !== "object" || err === null) return undefined;
const code = (err as { code?: unknown }).code;
if (typeof code === "string") return code;
// Dual-stack connects fail with an AggregateError whose per-address errors carry the code
const inner = (err as { errors?: unknown }).errors;
return Array.isArray(inner) ? inner.map(errnoOf).find(Boolean) : undefined;
}
/** A non-empty description of `err`; Node's AggregateErrors have an empty `message`. */
function describeError(err: unknown, errno: string | undefined): string {
const message = err instanceof Error ? err.message : String(err);
if (message) return message;
const inner = (err as { errors?: unknown } | null)?.errors;
if (Array.isArray(inner)) {
const parts = inner.map((e) => (e instanceof Error ? e.message : String(e))).filter(Boolean);
if (parts.length) return parts.join("; ");
}
return errno ?? (err instanceof Error ? err.name : "Unknown error");
}
/** Map any thrown value to a stable error code plus a human-readable message. */
export function classifyError(err: unknown): { code: LookupErrorCode; error: string } {
if (err instanceof RdapperError) return { code: err.code, error: err.message };
const name = err instanceof Error ? err.name : "";
const cause = err instanceof Error ? (err as { cause?: unknown }).cause : undefined;
const errno = errnoOf(err) ?? errnoOf(cause);
const message = describeError(err, errno);
if (name === "AbortError") return { code: "aborted", error: message };
if (name === "TimeoutError") return { code: "timeout", error: message };
// undici reports network failures as an opaque "fetch failed" with the details in `cause`
const error = message === "fetch failed" && errno ? `${message} (${errno})` : message;
if (errno === "ETIMEDOUT" || errno === "UND_ERR_CONNECT_TIMEOUT") {
return { code: "timeout", error };
}
if (errno && CONNECT_ERRNOS.has(errno)) return { code: "connect_failed", error };
return { code: "unknown", error };
}