Merge a6052fa253 into da71eaebd2
This commit is contained in:
commit
e52eb0c17c
89
src/infra/backoff.test.ts
Normal file
89
src/infra/backoff.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
64
src/infra/format-duration.test.ts
Normal file
64
src/infra/format-duration.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -87,6 +87,18 @@ describe("isTransientNetworkError", () => {
|
|||||||
expect(isTransientNetworkError(error)).toBe(true);
|
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", () => {
|
it("returns true for nested cause chain with network error", () => {
|
||||||
const innerCause = Object.assign(new Error("connection reset"), { code: "ECONNRESET" });
|
const innerCause = Object.assign(new Error("connection reset"), { code: "ECONNRESET" });
|
||||||
const outerCause = Object.assign(new Error("wrapper"), { cause: innerCause });
|
const outerCause = Object.assign(new Error("wrapper"), { cause: innerCause });
|
||||||
|
|||||||
@ -83,8 +83,14 @@ export function isTransientNetworkError(err: unknown): boolean {
|
|||||||
|
|
||||||
// "fetch failed" TypeError from undici (Node's native fetch)
|
// "fetch failed" TypeError from undici (Node's native fetch)
|
||||||
if (err instanceof TypeError && err.message === "fetch failed") {
|
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);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user