diff --git a/src/infra/unhandled-rejections.test.ts b/src/infra/unhandled-rejections.test.ts index b6008fdcfa3..3a04baec26b 100644 --- a/src/infra/unhandled-rejections.test.ts +++ b/src/infra/unhandled-rejections.test.ts @@ -400,6 +400,12 @@ describe("isTransientUnhandledRejectionError", () => { const wrappedDestroyedHttp2Session = Object.assign(new Error("model call failed"), { cause: destroyedHttp2Session, }); + const wsPreHandshakeClose = new Error( + "WebSocket was closed before the connection was established", + ); + const wrappedWsPreHandshakeClose = Object.assign(new Error("feishu reconnect failed"), { + cause: wsPreHandshakeClose, + }); const generic = new Error("boom"); expect(isBenignUncaughtExceptionError(epipe)).toBe(true); @@ -414,6 +420,13 @@ describe("isTransientUnhandledRejectionError", () => { expect(isBenignUncaughtExceptionError(destroyedHttp2Session)).toBe(true); expect(isBenignUncaughtExceptionError(wrappedDestroyedHttp2Session)).toBe(true); expect(isBenignUncaughtExceptionError(new Error("ERR_HTTP2_INVALID_SESSION"))).toBe(true); + expect(isBenignUncaughtExceptionError(wsPreHandshakeClose)).toBe(true); + expect(isBenignUncaughtExceptionError(wrappedWsPreHandshakeClose)).toBe(true); + expect( + isBenignUncaughtExceptionError( + new Error("WebSocket error: WebSocket was closed before the connection was established"), + ), + ).toBe(false); expect(isBenignUncaughtExceptionError(generic)).toBe(false); }); it("returns true for transient SQLite errors", () => { diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index afce28bbecc..243c5a8be16 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -111,6 +111,7 @@ const TRANSIENT_NETWORK_MESSAGE_CODE_RE = /\b(ECONNRESET|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED|EPIPE|ENETDOWN|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|EAI_AGAIN|EPROTO|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT|UND_ERR_SOCKET|UND_ERR_HEADERS_TIMEOUT|UND_ERR_BODY_TIMEOUT|ERR_HTTP2_INVALID_SESSION)\b/i; const BENIGN_UNCAUGHT_EXCEPTION_NETWORK_MESSAGE_CODE_RE = /\b(ECONNREFUSED|ENETDOWN|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT|ERR_HTTP2_INVALID_SESSION)\b/i; +const WS_PRE_HANDSHAKE_CLOSE_MESSAGE = "websocket was closed before the connection was established"; const TRANSIENT_SQLITE_MESSAGE_CODE_RE = /\b(SQLITE_BUSY|SQLITE_CANTOPEN|SQLITE_IOERR|SQLITE_LOCKED)\b/i; @@ -176,6 +177,16 @@ function isWrappedFetchFailedMessage(message: string): boolean { return /:\s*fetch failed$/.test(message); } +function isBenignUncaughtNetworkMessage(message: string): boolean { + if (BENIGN_UNCAUGHT_EXCEPTION_NETWORK_MESSAGE_CODE_RE.test(message)) { + return true; + } + + // `ws` emits this exact Error when close()/terminate() aborts a CONNECTING socket. + // Keep exact matching so arbitrary WebSocket errors still take the fatal path. + return message === WS_PRE_HANDSHAKE_CLOSE_MESSAGE; +} + function getErrorCause(err: unknown): unknown { if (!err || typeof err !== "object") { return undefined; @@ -437,7 +448,7 @@ function isBenignUncaughtNetworkException(err: unknown): boolean { continue; } const message = normalizeLowercaseStringOrEmpty((candidate as { message?: unknown }).message); - if (message && BENIGN_UNCAUGHT_EXCEPTION_NETWORK_MESSAGE_CODE_RE.test(message)) { + if (message && isBenignUncaughtNetworkMessage(message)) { return true; } }