From f4d567f10aac62f99cc72d89c66f106a9267aa00 Mon Sep 17 00:00:00 2001 From: Roopesh Date: Thu, 29 Jan 2026 19:51:05 +0530 Subject: [PATCH 1/3] 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/3] 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(); + }); +}); From a6052fa253e244bf8c19967448ce628c4ef06f50 Mon Sep 17 00:00:00 2001 From: Roopesh Date: Thu, 29 Jan 2026 22:46:26 +0530 Subject: [PATCH 3/3] Test(infra): Add unit tests for duration formatting (Issue #4025) --- src/infra/format-duration.test.ts | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/infra/format-duration.test.ts diff --git a/src/infra/format-duration.test.ts b/src/infra/format-duration.test.ts new file mode 100644 index 000000000..21f6505ac --- /dev/null +++ b/src/infra/format-duration.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { formatDurationMs, formatDurationSeconds } from "./format-duration.js"; + +describe("formatDurationSeconds", () => { + it("formats basic seconds correctly", () => { + // 1500ms = 1.5s + expect(formatDurationSeconds(1500)).toBe("1.5s"); + }); + + it("handles 0 correctly", () => { + expect(formatDurationSeconds(0)).toBe("0s"); + }); + + it("respects decimal precision option", () => { + // 1559ms = 1.559s -> rounds to 1.56s + // The implementation uses toFixed which rounds. + expect(formatDurationSeconds(1559, { decimals: 2 })).toBe("1.56s"); + expect(formatDurationSeconds(1559, { decimals: 0 })).toBe("2s"); + }); + + it("removes trailing zeros and decimal point if integer", () => { + expect(formatDurationSeconds(2000)).toBe("2s"); + expect(formatDurationSeconds(2100)).toBe("2.1s"); + expect(formatDurationSeconds(2100, { decimals: 2 })).toBe("2.1s"); // Not "2.10s" due to regex replacer + }); + + it("supports verbose unit label", () => { + expect(formatDurationSeconds(1500, { unit: "seconds" })).toBe("1.5 seconds"); + }); + + it("handles negative numbers as 0 (implementation detail)", () => { + // The implementation does Math.max(0, ms) + expect(formatDurationSeconds(-100)).toBe("0s"); + }); + + it("handles non-finite numbers", () => { + expect(formatDurationSeconds(NaN)).toBe("unknown"); + expect(formatDurationSeconds(Infinity)).toBe("unknown"); + }); +}); + +describe("formatDurationMs", () => { + it("formats sub-second values as ms", () => { + expect(formatDurationMs(500)).toBe("500ms"); + expect(formatDurationMs(999)).toBe("999ms"); + }); + + it("formats values >= 1000ms as seconds (defaulting to 2 decimals)", () => { + // Default decimals for formatDurationMs is 2 + expect(formatDurationMs(1000)).toBe("1s"); + expect(formatDurationMs(1500)).toBe("1.5s"); + expect(formatDurationMs(1234)).toBe("1.23s"); + }); + + it("passes options to formatDurationSeconds when >= 1000ms", () => { + expect(formatDurationMs(1234, { decimals: 1 })).toBe("1.2s"); + expect(formatDurationMs(1500, { unit: "seconds" })).toBe("1.5 seconds"); + }); + + it("handles non-finite numbers", () => { + expect(formatDurationMs(Infinity)).toBe("unknown"); + }); +});