fix: treat WHOIS empty-close as no_data and ETIMEDOUT as structured timeout

- A server that accepts the connection and closes it without sending data now rejects with `errorCode: "no_data"` and `stage: "read"` instead of resolving with an empty string
- OS-level `ETIMEDOUT` errors are mapped to the same `timeout` / `stage` shape as the library's own timer, with `stage` derived from whether the socket had connected yet
- `ECONNRESET` after data arrives is no longer marked `partial: true`; it was a complete reply and the reset just signalled the end of the stream
- `stage` on `LookupAttempt` is now documented as covering all WHOIS failures (not only timeout failures)
This commit is contained in:
2026-09-18 14:47:31 -04:00
parent 69f85ee925
commit e736045de1
4 changed files with 62 additions and 10 deletions
+1 -1
View File
@@ -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;
+28 -2
View File
@@ -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;
});
});
+32 -6
View File
@@ -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})`)),