fix: classify ws pre-handshake close as benign

Classify the exact `ws` pre-handshake close-before-open error as a benign uncaught network exception so transient Feishu WebSocket cleanup does not crash the gateway process.

The classifier now keeps the upstream `ws` message as an exact contract and rejects broader prefixed WebSocket messages, with regression coverage for direct, wrapped, and non-exact cases.

Fixes #88257.
Thanks @akrimm702.

Co-authored-by: AI-HUB <144416483+akrimm702@users.noreply.github.com>
This commit is contained in:
AI-HUB
2026-05-30 15:45:23 +02:00
committed by GitHub
parent 41e5acbb6c
commit f327073fb3
2 changed files with 25 additions and 1 deletions

View File

@@ -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", () => {

View File

@@ -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;
}
}