From 4f3b78aa5e3e88f55c4d7f498904c4ad4c587026 Mon Sep 17 00:00:00 2001 From: Ayush Ojha Date: Fri, 30 Jan 2026 00:11:34 -0800 Subject: [PATCH] fix(gateway): treat all fetch failed TypeErrors as transient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a TypeError("fetch failed") from undici had a cause with an unrecognized error code, isTransientNetworkError() would recurse into the cause, fail to match, and return false — causing the gateway to exit via process.exit(1). Since the outer TypeError("fetch failed") already indicates a network-level failure, treat it as transient unconditionally regardless of the cause chain contents. Added tests for fetch failures with unrecognized cause and no cause. Fixes #4425 --- ...handled-rejections.fatal-detection.test.ts | 26 +++++++++++++++++++ src/infra/unhandled-rejections.ts | 6 ++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/infra/unhandled-rejections.fatal-detection.test.ts b/src/infra/unhandled-rejections.fatal-detection.test.ts index e991c67c9..2ebd70166 100644 --- a/src/infra/unhandled-rejections.fatal-detection.test.ts +++ b/src/infra/unhandled-rejections.fatal-detection.test.ts @@ -114,6 +114,32 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { ); }); + it("does NOT exit on undici fetch failures with unrecognized cause", () => { + const fetchErr = Object.assign(new TypeError("fetch failed"), { + cause: new Error("some unknown undici error"), + }); + + process.emit("unhandledRejection", fetchErr, Promise.resolve()); + + expect(exitCalls).toEqual([]); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "[openclaw] Non-fatal unhandled rejection (continuing):", + expect.stringContaining("fetch failed"), + ); + }); + + it("does NOT exit on undici fetch failures without cause", () => { + const fetchErr = new TypeError("fetch failed"); + + process.emit("unhandledRejection", fetchErr, Promise.resolve()); + + expect(exitCalls).toEqual([]); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "[openclaw] Non-fatal unhandled rejection (continuing):", + expect.stringContaining("fetch failed"), + ); + }); + it("does NOT exit on DNS resolution failures", () => { const dnsErr = Object.assign(new Error("DNS resolve failed"), { code: "UND_ERR_DNS_RESOLVE_FAILED", diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index 4d2a48d23..45a92ba3a 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -81,10 +81,10 @@ export function isTransientNetworkError(err: unknown): boolean { const code = extractErrorCodeWithCause(err); if (code && TRANSIENT_NETWORK_CODES.has(code)) return true; - // "fetch failed" TypeError from undici (Node's native fetch) + // "fetch failed" TypeError from undici (Node's native fetch). + // Always treat as transient — the cause may carry an unknown code + // but the outer TypeError already indicates a network-level failure. if (err instanceof TypeError && err.message === "fetch failed") { - const cause = getErrorCause(err); - if (cause) return isTransientNetworkError(cause); return true; }