This commit is contained in:
Roopesh S 2026-01-30 11:55:31 +00:00 committed by GitHub
commit e52eb0c17c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 172 additions and 1 deletions

89
src/infra/backoff.test.ts Normal file
View File

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

View File

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

View File

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

View File

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