Skip to content

Commit 48e2438

Browse files
committed
fix: add jitter to rate-limit backoff
The runtime 429 backoff was purely exponential, so multiple clients or parallel workers could retry in lockstep and create avoidable bursts. Add bounded jitter when a new backoff window is created, while keeping duplicate 429s within the dedup window stable so the same retry window is reused instead of re-randomized.
1 parent 2b4a80a commit 48e2438

2 files changed

Lines changed: 33 additions & 8 deletions

File tree

lib/request/rate-limit-backoff.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface RateLimitBackoffResult {
1717
const RATE_LIMIT_DEDUP_WINDOW_MS = 2000;
1818
const RATE_LIMIT_STATE_RESET_MS = 120_000;
1919
const MAX_BACKOFF_MS = 60_000;
20+
const RATE_LIMIT_BACKOFF_JITTER_FACTOR = 0.2;
2021

2122
export const RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS = 5000;
2223

@@ -34,6 +35,7 @@ interface RateLimitState {
3435
consecutive429: number;
3536
lastAt: number;
3637
quotaKey: string;
38+
lastDelayMs: number;
3739
}
3840

3941
const rateLimitStateByAccountQuota = new Map<string, RateLimitState>();
@@ -43,6 +45,11 @@ function normalizeDelayMs(value: number | null | undefined, fallback: number): n
4345
return Math.max(0, Math.floor(candidate));
4446
}
4547

48+
function addBackoffJitter(baseMs: number): number {
49+
const jitter = baseMs * RATE_LIMIT_BACKOFF_JITTER_FACTOR * (Math.random() * 2 - 1);
50+
return Math.max(0, Math.floor(baseMs + jitter));
51+
}
52+
4653
function pruneStaleRateLimitState(): void {
4754
const now = Date.now();
4855
for (const [key, state] of rateLimitStateByAccountQuota) {
@@ -68,13 +75,9 @@ export function getRateLimitBackoff(
6875
const baseDelay = normalizeDelayMs(serverRetryAfterMs, 1000);
6976

7077
if (previous && now - previous.lastAt < RATE_LIMIT_DEDUP_WINDOW_MS) {
71-
const backoffDelay = Math.min(
72-
baseDelay * Math.pow(2, previous.consecutive429 - 1),
73-
MAX_BACKOFF_MS,
74-
);
7578
return {
7679
attempt: previous.consecutive429,
77-
delayMs: Math.max(baseDelay, backoffDelay),
80+
delayMs: previous.lastDelayMs,
7881
isDuplicate: true,
7982
};
8083
}
@@ -84,16 +87,18 @@ export function getRateLimitBackoff(
8487
? previous.consecutive429 + 1
8588
: 1;
8689

90+
const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), MAX_BACKOFF_MS);
91+
const jitteredDelay = Math.min(addBackoffJitter(backoffDelay), MAX_BACKOFF_MS);
92+
const delayMs = Math.max(baseDelay, jitteredDelay);
8793
rateLimitStateByAccountQuota.set(stateKey, {
8894
consecutive429: attempt,
8995
lastAt: now,
9096
quotaKey,
97+
lastDelayMs: delayMs,
9198
});
92-
93-
const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), MAX_BACKOFF_MS);
9499
return {
95100
attempt,
96-
delayMs: Math.max(baseDelay, backoffDelay),
101+
delayMs,
97102
isDuplicate: false,
98103
};
99104
}

test/rate-limit-backoff.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ describe("Rate limit backoff", () => {
1212
vi.useFakeTimers();
1313
vi.setSystemTime(new Date(0));
1414
clearRateLimitBackoffState();
15+
vi.spyOn(Math, "random").mockReturnValue(0.5);
1516
});
1617

1718
afterEach(() => {
1819
clearRateLimitBackoffState();
20+
vi.restoreAllMocks();
1921
vi.useRealTimers();
2022
});
2123

@@ -39,6 +41,24 @@ describe("Rate limit backoff", () => {
3941
expect(second.isDuplicate).toBe(false);
4042
});
4143

44+
it("applies jitter to new backoff windows but keeps duplicate retries stable", () => {
45+
vi.mocked(Math.random).mockReturnValueOnce(1);
46+
const first = getRateLimitBackoff(4, "jitter-test", 1000);
47+
expect(first.delayMs).toBe(1200);
48+
49+
vi.setSystemTime(new Date(1000));
50+
vi.mocked(Math.random).mockReturnValueOnce(0);
51+
const duplicate = getRateLimitBackoff(4, "jitter-test", 1000);
52+
expect(duplicate.delayMs).toBe(1200);
53+
expect(duplicate.isDuplicate).toBe(true);
54+
55+
vi.setSystemTime(new Date(2500));
56+
vi.mocked(Math.random).mockReturnValueOnce(0);
57+
const second = getRateLimitBackoff(4, "jitter-test", 1000);
58+
expect(second.delayMs).toBe(1600);
59+
expect(second.isDuplicate).toBe(false);
60+
});
61+
4262
it("resets after quiet period", () => {
4363
getRateLimitBackoff(0, "codex", 1000);
4464
vi.setSystemTime(new Date(121_000));

0 commit comments

Comments
 (0)