fix(ai): Add jitter to agent retries

Spread concurrent retries, including at the delay ceiling, without
changing retry limits.

Avoids thundering herd issues in workflows with many subagents.
This commit is contained in:
Flaviu Tamas 2026-11-05 09:09:03 -05:00
parent 55a5c69145
commit 2e58814842
No known key found for this signature in database
GPG key ID: D6BF32C876496756
2 changed files with 27 additions and 3 deletions

View file

@ -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<RetryPolicy, "baseDelayMs" | "maxAgentDelayMs">, 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. */

View file

@ -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 () => {