diff --git a/packages/cli/src/lib/error-reporting.ts b/packages/cli/src/lib/error-reporting.ts index 9e7bf3ad4..529b4589f 100644 --- a/packages/cli/src/lib/error-reporting.ts +++ b/packages/cli/src/lib/error-reporting.ts @@ -76,9 +76,11 @@ export function classifySilenced(error: unknown): SilenceReason | null { return "output_error"; } // A raw `TypeError: "fetch failed"` (CLI-16W) means the CLI could not reach - // Sentry at all (offline, DNS, connection refused/timeout). There is nothing - // actionable in a "user is offline" report, so drop it — same rationale as - // EPIPE/EBADF OS noise in `beforeSend`. Note: TLS cert errors are wrapped as + // Sentry at all (offline, DNS, connection refused/timeout); a raw + // `TypeError: "terminated"` (CLI-2AD) means the connection dropped while + // streaming the response body. There is nothing actionable in a "user is + // offline" report, so drop it — same rationale as EPIPE/EBADF OS noise in + // `beforeSend`. Note: TLS cert errors are wrapped as // ApiError(status 0), NOT matched here, so they stay captured/actionable. if (isNetworkError(error)) { return "network_error"; @@ -116,6 +118,13 @@ export function classifySilenced(error: unknown): SilenceReason | null { if (error instanceof ValidationError && error.field === "input") { return "user_input_error"; } + // A ValidationError with field "directory" means the sourcemap directory + // the user passed is missing, not a directory, or unreadable. Pure + // user-input noise, not a CLI bug — the user sees the path and the + // reason (CLI-1FN). + if (error instanceof ValidationError && error.field === "directory") { + return "user_input_error"; + } // A ResolutionError means the user provided a value (event ID, project slug, // etc.) that was looked up but not found. This is pure user-input noise, not // a CLI bug — the user sees a clear "not found" message (CLI-RP). diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index 0a7230b55..171b3e676 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -911,6 +911,12 @@ export function toSearchQueryError( * `TypeError: "fetch failed"` for every such failure (the real errno is in * `cause`). * + * When the connection drops *after* response headers arrive (e.g. `read + * ETIMEDOUT` while streaming the body), undici instead aborts the body stream + * with `TypeError: "terminated"` (CLI-2AD). `sentry-client.ts` already retries + * finite GET bodies, so one that still escapes is the same class of + * environmental failure and is matched too. + * * This reflects the user's environment, not a CLI bug, so it is treated as a * user error (no upgrade nudge) and is not reported to Sentry — the same * rationale as dropping EPIPE/EBADF OS noise in `beforeSend`. @@ -922,7 +928,10 @@ export function toSearchQueryError( * CLI bug" treatment for `status === 0` check it explicitly. */ export function isNetworkError(error: unknown): boolean { - return error instanceof TypeError && error.message === "fetch failed"; + return ( + error instanceof TypeError && + (error.message === "fetch failed" || error.message === "terminated") + ); } /** diff --git a/packages/cli/test/lib/error-reporting.test.ts b/packages/cli/test/lib/error-reporting.test.ts index f70d5785e..a7cb7ac17 100644 --- a/packages/cli/test/lib/error-reporting.test.ts +++ b/packages/cli/test/lib/error-reporting.test.ts @@ -256,6 +256,13 @@ describe("classifySilenced", () => { ); }); + test("silences a raw 'terminated' TypeError (mid-body connection drop)", () => { + const err = new TypeError("terminated", { + cause: new Error("read ETIMEDOUT"), + }); + expect(classifySilenced(err)).toBe("network_error"); + }); + test("does NOT silence a TLS cert error wrapped as ApiError(0)", () => { // status 0 is shared by network failures and TLS cert errors; the latter // are actionable (missing CA) and must stay captured. @@ -334,6 +341,18 @@ describe("classifySilenced", () => { ).toBe("user_input_error"); }); + test("silences ValidationError with field 'directory' (bad sourcemap directory)", () => { + // A missing sourcemap upload/inject directory is user-input noise (CLI-1FN). + expect( + classifySilenced( + new ValidationError( + "Directory '/app/.next/static/chunks' does not exist.", + "directory", + ), + ), + ).toBe("user_input_error"); + }); + test("silences ResolutionError (user provided a value that wasn't found)", () => { expect( classifySilenced( diff --git a/packages/cli/test/lib/errors.test.ts b/packages/cli/test/lib/errors.test.ts index e20aae7b8..376893d58 100644 --- a/packages/cli/test/lib/errors.test.ts +++ b/packages/cli/test/lib/errors.test.ts @@ -536,6 +536,11 @@ describe("isUserError", () => { new TypeError("fetch failed"), true, ], + [ + "raw terminated TypeError (mid-body network drop)", + new TypeError("terminated"), + true, + ], ["ApiError 400", new ApiError("bad request", 400), false], [ // A user's unparseable --query becomes a ValidationError at the command @@ -565,6 +570,18 @@ describe("isNetworkError", () => { expect(isNetworkError(new TypeError("fetch failed"))).toBe(true); }); + test("true for a raw 'terminated' TypeError (mid-body connection drop)", () => { + const err = new TypeError("terminated", { + cause: new Error("read ETIMEDOUT"), + }); + expect(isNetworkError(err)).toBe(true); + expect(isNetworkError(new TypeError("terminated"))).toBe(true); + }); + + test("false for a plain Error with message 'terminated'", () => { + expect(isNetworkError(new Error("terminated"))).toBe(false); + }); + test("false for ApiError status 0 (shared with TLS cert errors)", () => { // status 0 is also used for TLS cert errors, which must stay actionable. expect(isNetworkError(new ApiError("Network error", 0))).toBe(false);