mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-23 00:15: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:
@@ -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:
|
`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:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -416,7 +416,7 @@ export interface LookupAttempt {
|
|||||||
durationMs: number;
|
durationMs: number;
|
||||||
errorCode?: LookupErrorCode;
|
errorCode?: LookupErrorCode;
|
||||||
error?: string;
|
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";
|
stage?: "connect" | "read";
|
||||||
/** WHOIS only: the read timed out after some data arrived, so the text is partial */
|
/** WHOIS only: the read timed out after some data arrived, so the text is partial */
|
||||||
partial?: boolean;
|
partial?: boolean;
|
||||||
|
|||||||
@@ -113,12 +113,38 @@ describe("whoisQuery timeouts", () => {
|
|||||||
).rejects.toMatchObject({ code: "aborted" });
|
).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");
|
const p = whoisQuery("whois.example", "example.test");
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
socket.emit("connect");
|
socket.emit("connect");
|
||||||
socket.emit("data", Buffer.from("answer"));
|
socket.emit("data", Buffer.from("answer"));
|
||||||
socket.emit("error", Object.assign(new Error("reset"), { code: "ECONNRESET" }));
|
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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+31
-5
@@ -109,9 +109,18 @@ async function queryTcp(
|
|||||||
signal?.addEventListener("abort", onAbort, { once: true });
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
socket.on("error", (err: NodeJS.ErrnoException) => {
|
socket.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
// Servers that reset the connection after replying still gave us an answer
|
|
||||||
if (err.code === "ECONNRESET" && received > 0) {
|
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 {
|
} else {
|
||||||
finish(() => reject(err));
|
finish(() => reject(err));
|
||||||
}
|
}
|
||||||
@@ -121,12 +130,29 @@ async function queryTcp(
|
|||||||
chunks.push(buf);
|
chunks.push(buf);
|
||||||
received += buf.length;
|
received += buf.length;
|
||||||
});
|
});
|
||||||
socket.on("end", () => {
|
// 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() }));
|
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
|
// A close without a preceding end/error (half-open teardown) would otherwise wait out the timer
|
||||||
socket.on("close", () => {
|
socket.on("close", () => {
|
||||||
if (connected) finish(() => resolve({ text: text() }));
|
if (connected) complete();
|
||||||
else {
|
else {
|
||||||
finish(() =>
|
finish(() =>
|
||||||
reject(new RdapperError("connect_failed", `WHOIS connection closed (${host})`)),
|
reject(new RdapperError("connect_failed", `WHOIS connection closed (${host})`)),
|
||||||
|
|||||||
Reference in New Issue
Block a user