diff --git a/packages/memory-host-sdk/src/host/retry-utils.test.ts b/packages/memory-host-sdk/src/host/retry-utils.test.ts index 9a2ebd8ef0f..908b6cbabf0 100644 --- a/packages/memory-host-sdk/src/host/retry-utils.test.ts +++ b/packages/memory-host-sdk/src/host/retry-utils.test.ts @@ -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>() + .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; + }); + + 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>() + .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; + }); + + 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); + }); }); diff --git a/packages/memory-host-sdk/src/host/retry-utils.ts b/packages/memory-host-sdk/src/host/retry-utils.ts index be2c6fdab63..ff632f15178 100644 --- a/packages/memory-host-sdk/src/host/retry-utils.ts +++ b/packages/memory-host-sdk/src/host/retry-utils.ts @@ -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 { 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( 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( 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);