Skip to content

Commit 325f630

Browse files
authored
fix(error-reporting): silence undici TypeError: terminated network errors (CLI-2AD)
1 parent 44e8a2f commit 325f630

4 files changed

Lines changed: 36 additions & 2 deletions

File tree

‎packages/cli/src/lib/error-reporting.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ export function classifySilenced(error: unknown): SilenceReason | null {
7676
return "output_error";
7777
}
7878
// A raw `TypeError: "fetch failed"` (CLI-16W) means the CLI could not reach
79-
// Sentry at all (offline, DNS, connection refused/timeout). There is nothing
79+
// Sentry at all (offline, DNS, connection refused/timeout); a raw
80+
// `TypeError: "terminated"` (CLI-2AD) means the connection dropped while
81+
// streaming the response body. There is nothing
8082
// actionable in a "user is offline" report, so drop it — same rationale as
8183
// EPIPE/EBADF OS noise in `beforeSend`. Note: TLS cert errors are wrapped as
8284
// ApiError(status 0), NOT matched here, so they stay captured/actionable.

‎packages/cli/src/lib/errors.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,11 @@ export function toSearchQueryError(
911911
* `TypeError: "fetch failed"` for every such failure (the real errno is in
912912
* `cause`).
913913
*
914+
* When the connection drops *after* response headers arrive (e.g. `read
915+
* ETIMEDOUT` while streaming the body), undici instead aborts the body stream
916+
* with `TypeError: "terminated"` (CLI-2AD). That is the same class of
917+
* environmental failure, so it is matched too.
918+
*
914919
* This reflects the user's environment, not a CLI bug, so it is treated as a
915920
* user error (no upgrade nudge) and is not reported to Sentry — the same
916921
* rationale as dropping EPIPE/EBADF OS noise in `beforeSend`.
@@ -922,7 +927,10 @@ export function toSearchQueryError(
922927
* CLI bug" treatment for `status === 0` check it explicitly.
923928
*/
924929
export function isNetworkError(error: unknown): boolean {
925-
return error instanceof TypeError && error.message === "fetch failed";
930+
return (
931+
error instanceof TypeError &&
932+
(error.message === "fetch failed" || error.message === "terminated")
933+
);
926934
}
927935

928936
/**

‎packages/cli/test/lib/error-reporting.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,13 @@ describe("classifySilenced", () => {
251251
);
252252
});
253253

254+
test("silences a raw 'terminated' TypeError (mid-body connection drop)", () => {
255+
const err = new TypeError("terminated", {
256+
cause: new Error("read ETIMEDOUT"),
257+
});
258+
expect(classifySilenced(err)).toBe("network_error");
259+
});
260+
254261
test("does NOT silence a TLS cert error wrapped as ApiError(0)", () => {
255262
// status 0 is shared by network failures and TLS cert errors; the latter
256263
// are actionable (missing CA) and must stay captured.

‎packages/cli/test/lib/errors.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,11 @@ describe("isUserError", () => {
536536
new TypeError("fetch failed"),
537537
true,
538538
],
539+
[
540+
"raw terminated TypeError (mid-body network drop)",
541+
new TypeError("terminated"),
542+
true,
543+
],
539544
["ApiError 400", new ApiError("bad request", 400), false],
540545
[
541546
// A user's unparseable --query becomes a ValidationError at the command
@@ -565,6 +570,18 @@ describe("isNetworkError", () => {
565570
expect(isNetworkError(new TypeError("fetch failed"))).toBe(true);
566571
});
567572

573+
test("true for a raw 'terminated' TypeError (mid-body connection drop)", () => {
574+
const err = new TypeError("terminated", {
575+
cause: new Error("read ETIMEDOUT"),
576+
});
577+
expect(isNetworkError(err)).toBe(true);
578+
expect(isNetworkError(new TypeError("terminated"))).toBe(true);
579+
});
580+
581+
test("false for a plain Error with message 'terminated'", () => {
582+
expect(isNetworkError(new Error("terminated"))).toBe(false);
583+
});
584+
568585
test("false for ApiError status 0 (shared with TLS cert errors)", () => {
569586
// status 0 is also used for TLS cert errors, which must stay actionable.
570587
expect(isNetworkError(new ApiError("Network error", 0))).toBe(false);

0 commit comments

Comments
 (0)