diff --git a/README.md b/README.md index c7bef8b..0cd53ab 100644 --- a/README.md +++ b/README.md @@ -469,7 +469,7 @@ Each entry in `attempts` describes one operation, successful or not, so a failur } ``` -`stage` (`"connect"` or `"read"`) is set on WHOIS timeouts. If a WHOIS server sends some data but never closes the connection, the timeout **resolves with the partial text** instead of failing, and the attempt is marked `partial: true`. +`stage` (`"connect"` or `"read"`) is set on WHOIS timeouts, and `"read"` also marks a server that accepted the connection and closed it without sending anything (`errorCode: "no_data"`, not a successful empty answer). If a WHOIS server sends some data but never closes the connection, the timeout **resolves with the partial text** instead of failing, and the attempt is marked `partial: true`. `timeoutMs` applies to each network operation (including reading the response body), not to the lookup as a whole. Without `deadlineMs`, the worst case is roughly `timeoutMs × (1 bootstrap + N RDAP servers + up to 2 RDAP links + 1 IANA + 1 + maxWhoisReferralHops WHOIS queries)`. Set `deadlineMs` to put a hard cap on the total, e.g. for serverless functions with an execution limit: diff --git a/src/types.ts b/src/types.ts index c14e8a6..fb1b4d0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -416,7 +416,7 @@ export interface LookupAttempt { durationMs: number; errorCode?: LookupErrorCode; error?: string; - /** WHOIS timeouts only: whether the socket never connected or connected but sent nothing */ + /** WHOIS failures only: `connect` if the socket never connected; `read` if it connected but sent nothing (timeout, or closed without a response, which is `no_data`) */ stage?: "connect" | "read"; /** WHOIS only: the read timed out after some data arrived, so the text is partial */ partial?: boolean; diff --git a/src/whois/client.timeout.test.ts b/src/whois/client.timeout.test.ts index 76cc174..e9a21ab 100644 --- a/src/whois/client.timeout.test.ts +++ b/src/whois/client.timeout.test.ts @@ -113,12 +113,38 @@ describe("whoisQuery timeouts", () => { ).rejects.toMatchObject({ code: "aborted" }); }); - it("keeps the data when a server resets the connection after replying", async () => { + it("keeps the data (not flagged partial) when a server resets 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 }); + const res = await p; + expect(res.text).toBe("answer"); + expect(res.partial).toBeUndefined(); + }); + + it.each(["end", "close"])( + "rejects with no_data when the server sends nothing then %s", + async (evt) => { + const p = whoisQuery("whois.example", "example.test"); + const assertion = expect(p).rejects.toMatchObject({ code: "no_data", stage: "read" }); + await vi.advanceTimersByTimeAsync(0); + socket.emit("connect"); + socket.emit(evt); + await assertion; + }, + ); + + it.each([ + [false, "connect"], + [true, "read"], + ])("gives an OS ETIMEDOUT (connected=%s) stage %s", async (connected, stage) => { + const p = whoisQuery("whois.example", "example.test"); + const assertion = expect(p).rejects.toMatchObject({ code: "timeout", stage }); + await vi.advanceTimersByTimeAsync(0); + if (connected) socket.emit("connect"); + socket.emit("error", Object.assign(new Error("connect ETIMEDOUT"), { code: "ETIMEDOUT" })); + await assertion; }); }); diff --git a/src/whois/client.ts b/src/whois/client.ts index 23e8cf7..043f2f2 100644 --- a/src/whois/client.ts +++ b/src/whois/client.ts @@ -109,9 +109,18 @@ async function queryTcp( 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 })); + // The server replied and then reset. Nothing says the reply was cut short, so this is + // a normal result; `partial` is reserved for our own read timeout. + finish(() => resolve({ text: text() })); + } else if (err.code === "ETIMEDOUT") { + // OS-level timeout: keep the same shape as our own timer's errors + const stage = connected ? "read" : "connect"; + finish(() => + reject( + new RdapperError("timeout", `WHOIS ${stage} timeout (${host})`, { stage, cause: err }), + ), + ); } else { finish(() => reject(err)); } @@ -121,12 +130,29 @@ async function queryTcp( chunks.push(buf); received += buf.length; }); - socket.on("end", () => { - finish(() => resolve({ text: text() })); - }); + // A connection that closes without sending anything (e.g. a throttled client being dropped) + // is a failure, not an empty successful answer. + const complete = () => { + if (received > 0) { + finish(() => resolve({ text: text() })); + } else { + finish(() => + reject( + new RdapperError( + "no_data", + `WHOIS server closed the connection without a response (${host})`, + { + stage: "read", + }, + ), + ), + ); + } + }; + socket.on("end", complete); // 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() })); + if (connected) complete(); else { finish(() => reject(new RdapperError("connect_failed", `WHOIS connection closed (${host})`)),