diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index eabf6c7de..384c8508f 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -125,7 +125,7 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([ ]); /** - * Retry policy: bounded attempts with exponential backoff (`baseDelayMs * 2^(attempt-1)`). + * Retry policy: bounded attempts with exponential backoff and up to 25% downward jitter. * `maxAgentDelayMs` caps each computed delay and defaults to 60 seconds. * Matches `settings.retry` (`enabled`, `maxRetries`, `baseDelayMs`, `maxAgentDelayMs`) in coding-agent; kept * here so the classifier and the policy-driven retry loop live together and stay reusable @@ -146,7 +146,8 @@ export const DEFAULT_MAX_AGENT_RETRY_DELAY_MS = 60_000; export function retryDelayMs(policy: Pick, attempt: number): number { const delay = policy.baseDelayMs * 2 ** Math.max(0, attempt - 1); const safeDelay = Number.isSafeInteger(delay) ? delay : Number.MAX_SAFE_INTEGER; - return Math.min(safeDelay, policy.maxAgentDelayMs ?? DEFAULT_MAX_AGENT_RETRY_DELAY_MS); + const cappedDelay = Math.min(safeDelay, policy.maxAgentDelayMs ?? DEFAULT_MAX_AGENT_RETRY_DELAY_MS); + return cappedDelay * (1 - Math.random() * 0.25); } /** Optional callbacks emitted by {@link retryAssistantCall} around each retry. */ diff --git a/packages/ai/test/retry.test.ts b/packages/ai/test/retry.test.ts index e943767f8..f4442e26d 100644 --- a/packages/ai/test/retry.test.ts +++ b/packages/ai/test/retry.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { fauxAssistantMessage } from "../src/providers/faux.ts"; import { isRetryableAssistantError, type RetryPolicy, retryAssistantCall, retryDelayMs } from "../src/utils/retry.ts"; @@ -166,8 +166,30 @@ describe("provider retry classification", () => { }); }); +afterEach(() => vi.restoreAllMocks()); + describe("retryDelayMs", () => { + it.each([ + [0, 2000], + [0.5, 1750], + [0.999, 1500.5], + ])("jitters exponential backoff with random value %s", (random, expected) => { + vi.spyOn(Math, "random").mockReturnValue(random); + + expect(retryDelayMs({ baseDelayMs: 2000 }, 1)).toBeCloseTo(expected); + expect(retryDelayMs({ baseDelayMs: 2000 }, 2)).toBeCloseTo(expected * 2); + }); + + it("jitters capped delays so retries stay spread at the ceiling", () => { + vi.spyOn(Math, "random").mockReturnValue(0.5); + + expect(retryDelayMs({ baseDelayMs: 2000 }, 6)).toBe(52500); + expect(retryDelayMs({ baseDelayMs: 2000, maxAgentDelayMs: 5000 }, 5)).toBe(4375); + expect(retryDelayMs({ baseDelayMs: 2000, maxAgentDelayMs: 0 }, 5)).toBe(0); + }); + it("caps agent retry delay", () => { + vi.spyOn(Math, "random").mockReturnValue(0); // Regression for #8826. expect(retryDelayMs({ baseDelayMs: 2000 }, 6)).toBe(60000); expect(retryDelayMs({ baseDelayMs: 2000, maxAgentDelayMs: 5000 }, 5)).toBe(5000); @@ -221,6 +243,7 @@ describe("retryAssistantCall", () => { it("reports capped retry delays", async () => { // Regression for #8826. + vi.spyOn(Math, "random").mockReturnValue(0); let n = 0; const policy: RetryPolicy = { enabled: true, maxRetries: 4, baseDelayMs: 10, maxAgentDelayMs: 15 }; const produce = vi.fn(async () => {