fix(memory): cap retry sleep delays

This commit is contained in:
Peter Steinberger
2026-05-30 02:21:17 -04:00
parent 94df665cdc
commit 7c3d7fc6e3
2 changed files with 68 additions and 7 deletions

View File

@@ -1,6 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MAX_SAFE_TIMEOUT_DELAY_MS } from "../../../gateway-client/src/timeouts.js";
import { resolveRetryConfig, retryAsync } from "./retry-utils.js";
afterEach(() => {
vi.restoreAllMocks();
});
describe("resolveRetryConfig", () => {
const defaults = {
attempts: 4,
@@ -14,6 +19,16 @@ describe("resolveRetryConfig", () => {
expect(resolveRetryConfig(defaults, { attempts: Number.POSITIVE_INFINITY }).attempts).toBe(4);
expect(resolveRetryConfig(defaults, { attempts: Number.NaN }).attempts).toBe(4);
});
it("caps oversized retry delays at the timer-safe ceiling", () => {
const config = resolveRetryConfig(defaults, {
minDelayMs: Number.MAX_SAFE_INTEGER,
maxDelayMs: Number.MAX_SAFE_INTEGER,
});
expect(config.minDelayMs).toBe(MAX_SAFE_TIMEOUT_DELAY_MS);
expect(config.maxDelayMs).toBe(MAX_SAFE_TIMEOUT_DELAY_MS);
});
});
describe("retryAsync", () => {
@@ -26,4 +41,45 @@ describe("retryAsync", () => {
expect(run).toHaveBeenCalledTimes(3);
});
it("caps legacy numeric retry sleeps at the timer-safe ceiling", async () => {
const run = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce("ok");
const timeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation((callback) => {
if (typeof callback === "function") {
callback();
}
return 0 as unknown as ReturnType<typeof setTimeout>;
});
await expect(retryAsync(run, 2, Number.MAX_SAFE_INTEGER)).resolves.toBe("ok");
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS);
});
it("caps retryAfterMs sleeps at the timer-safe ceiling", async () => {
const run = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce("ok");
const timeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation((callback) => {
if (typeof callback === "function") {
callback();
}
return 0 as unknown as ReturnType<typeof setTimeout>;
});
await expect(
retryAsync(run, {
attempts: 2,
minDelayMs: 0,
maxDelayMs: Number.MAX_SAFE_INTEGER,
retryAfterMs: () => Number.MAX_SAFE_INTEGER,
}),
).resolves.toBe("ok");
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS);
});
});

View File

@@ -1,3 +1,5 @@
import { resolveSafeTimeoutDelayMs } from "../../../gateway-client/src/timeouts.js";
export type RetryConfig = {
attempts?: number;
minDelayMs?: number;
@@ -60,13 +62,16 @@ export function resolveRetryConfig(
overrides?: RetryConfig,
): Required<RetryConfig> {
const attempts = resolveAttempts(overrides?.attempts, defaults.attempts);
const minDelayMs = Math.max(
0,
const minDelayMs = resolveSafeTimeoutDelayMs(
Math.round(clampNumber(overrides?.minDelayMs, defaults.minDelayMs, 0)),
{ minMs: 0 },
);
const maxDelayMs = Math.max(
minDelayMs,
Math.round(clampNumber(overrides?.maxDelayMs, defaults.maxDelayMs, 0)),
resolveSafeTimeoutDelayMs(
Math.round(clampNumber(overrides?.maxDelayMs, defaults.maxDelayMs, 0)),
{ minMs: 0 },
),
);
const jitter = clampNumber(overrides?.jitter, defaults.jitter, 0, 1);
return { attempts, minDelayMs, maxDelayMs, jitter };
@@ -96,7 +101,7 @@ export async function retryAsync<T>(
if (i === attempts - 1) {
break;
}
await sleep(initialDelayMs * 2 ** i);
await sleep(resolveSafeTimeoutDelayMs(initialDelayMs * 2 ** i, { minMs: 0 }));
}
}
throw lastErr ?? new Error("Retry failed");
@@ -125,8 +130,8 @@ export async function retryAsync<T>(
const retryAfterMs = options.retryAfterMs?.(err);
const hasRetryAfter = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs);
const baseDelay = hasRetryAfter
? Math.max(retryAfterMs, minDelayMs)
: minDelayMs * 2 ** (attempt - 1);
? Math.max(resolveSafeTimeoutDelayMs(retryAfterMs, { minMs: 0 }), minDelayMs)
: resolveSafeTimeoutDelayMs(minDelayMs * 2 ** (attempt - 1), { minMs: 0 });
let delay = Math.min(baseDelay, maxDelayMs);
delay = applyJitter(delay, resolved.jitter);
delay = Math.min(Math.max(delay, minDelayMs), maxDelayMs);