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