From f4d567f10aac62f99cc72d89c66f106a9267aa00 Mon Sep 17 00:00:00 2001 From: Roopesh Date: Thu, 29 Jan 2026 19:51:05 +0530 Subject: [PATCH 1/2] Fix(gateway): Prevent crash on unhandled fetch rejections (close #3974) --- src/infra/unhandled-rejections.test.ts | 12 ++++++++++++ src/infra/unhandled-rejections.ts | 8 +++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/infra/unhandled-rejections.test.ts b/src/infra/unhandled-rejections.test.ts index 1ec144ba1..696ee1310 100644 --- a/src/infra/unhandled-rejections.test.ts +++ b/src/infra/unhandled-rejections.test.ts @@ -87,6 +87,18 @@ describe("isTransientNetworkError", () => { expect(isTransientNetworkError(error)).toBe(true); }); + it("returns true for fetch failed with unknown cause (Regression Test for #3974)", () => { + // Simulate Cause with NO code + const causeNoCode = new Error("Some obscure network error"); + const errorNoCode = Object.assign(new TypeError("fetch failed"), { cause: causeNoCode }); + expect(isTransientNetworkError(errorNoCode)).toBe(true); + + // Simulate Cause with UNKNOWN code + const causeUnknown = Object.assign(new Error("Unknown"), { code: "UNKNOWN_123" }); + const errorUnknown = Object.assign(new TypeError("fetch failed"), { cause: causeUnknown }); + expect(isTransientNetworkError(errorUnknown)).toBe(true); + }); + it("returns true for nested cause chain with network error", () => { const innerCause = Object.assign(new Error("connection reset"), { code: "ECONNRESET" }); const outerCause = Object.assign(new Error("wrapper"), { cause: innerCause }); diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index d186c6a78..7310acfe1 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -83,8 +83,14 @@ export function isTransientNetworkError(err: unknown): boolean { // "fetch failed" TypeError from undici (Node's native fetch) if (err instanceof TypeError && err.message === "fetch failed") { + // Almost all cases of "fetch failed" are network related (DNS, timeout, connection refuse). + // If we can't determine otherwise, assume transient to prevent crashing the gateway. const cause = getErrorCause(err); - if (cause) return isTransientNetworkError(cause); + if (cause) { + if (isTransientNetworkError(cause)) return true; + // If cause exists but isn't explicitly fatal, treat "fetch failed" as transient + if (!isFatalError(cause) && !isConfigError(cause)) return true; + } return true; } From 9b029e9e63633cacaa8c9cfc67c03594edffde1a Mon Sep 17 00:00:00 2001 From: Roopesh Date: Thu, 29 Jan 2026 20:05:46 +0530 Subject: [PATCH 2/2] Test(infra): Add unit tests for backoff and sleep logic (Issue #4013) --- src/infra/backoff.test.ts | 89 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/infra/backoff.test.ts diff --git a/src/infra/backoff.test.ts b/src/infra/backoff.test.ts new file mode 100644 index 000000000..0abc2fce3 --- /dev/null +++ b/src/infra/backoff.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; + +import { computeBackoff, sleepWithAbort } from "./backoff.js"; + +describe("computeBackoff", () => { + const policy = { + initialMs: 1000, + maxMs: 10000, + factor: 2, + jitter: 0, // Disable jitter for deterministic tests unless specified + }; + + it("returns initialMs for the first attempt (attempt 1)", () => { + // attempt 1 => factor^(1-1) = factor^0 = 1 + // 1000 * 1 = 1000 + expect(computeBackoff(policy, 1)).toBe(1000); + }); + + it("returns calculated backoff for subsequent attempts", () => { + // attempt 2 => 1000 * 2^1 = 2000 + expect(computeBackoff(policy, 2)).toBe(2000); + // attempt 3 => 1000 * 2^2 = 4000 + expect(computeBackoff(policy, 3)).toBe(4000); + }); + + it("caps the backoff at maxMs", () => { + // attempt 10 => 1000 * 2^9 = 512000 > 10000 + expect(computeBackoff(policy, 10)).toBe(10000); + }); + + it("treats attempt 0 or negative as attempt 1", () => { + expect(computeBackoff(policy, 0)).toBe(1000); + expect(computeBackoff(policy, -1)).toBe(1000); + }); + + it("applies jitter", () => { + const jitterPolicy = { ...policy, jitter: 0.5 }; // 50% jitter + // Base for attempt 2 is 2000. + // jitter = 2000 * 0.5 * Math.random() => [0, 1000] + // result = 2000 + [0, 1000] => [2000, 3000] + + // We run it multiple times to ensure we get variance + const results = new Set(); + for (let i = 0; i < 50; i++) { + const val = computeBackoff(jitterPolicy, 2); + results.add(val); + expect(val).toBeGreaterThanOrEqual(2000); + expect(val).toBeLessThanOrEqual(3000); + } + expect(results.size).toBeGreaterThan(1); + }); +}); + +describe("sleepWithAbort", () => { + it("resolves after the specified duration", async () => { + vi.useFakeTimers(); + const sleepPromise = sleepWithAbort(1000); + + // Should not resolve yet + vi.advanceTimersByTime(500); + + // Advance remaining time + vi.advanceTimersByTime(500); + await expect(sleepPromise).resolves.toBeUndefined(); + vi.useRealTimers(); + }); + + it("rejects immediately if abort signal is triggered", async () => { + const controller = new AbortController(); + const sleepPromise = sleepWithAbort(5000, controller.signal); + + controller.abort(); + + await expect(sleepPromise).rejects.toThrow("aborted"); + }); + + it("rejects immediately if abort signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + const sleepPromise = sleepWithAbort(5000, controller.signal); + await expect(sleepPromise).rejects.toThrow("aborted"); + }); + + it("resolves immediately for 0 or negative ms", async () => { + await expect(sleepWithAbort(0)).resolves.toBeUndefined(); + await expect(sleepWithAbort(-100)).resolves.toBeUndefined(); + }); +});