Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions lib/request/rate-limit-backoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ export interface RateLimitBackoffResult {
const RATE_LIMIT_DEDUP_WINDOW_MS = 2000;
const RATE_LIMIT_STATE_RESET_MS = 120_000;
const MAX_BACKOFF_MS = 60_000;
const RATE_LIMIT_BACKOFF_JITTER_FACTOR = 0.2;

export const RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS = 5000;

interface RateLimitState {
consecutive429: number;
lastAt: number;
quotaKey: string;
lastDelayMs: number;
}

const rateLimitStateByAccountQuota = new Map<string, RateLimitState>();
Expand All @@ -33,6 +35,11 @@ function normalizeDelayMs(value: number | null | undefined, fallback: number): n
return Math.max(0, Math.floor(candidate));
}

function addBackoffJitter(baseMs: number): number {
const jitter = baseMs * RATE_LIMIT_BACKOFF_JITTER_FACTOR * (Math.random() * 2 - 1);
return Math.max(0, Math.floor(baseMs + jitter));
}

function pruneStaleRateLimitState(): void {
const now = Date.now();
for (const [key, state] of rateLimitStateByAccountQuota) {
Expand All @@ -58,13 +65,9 @@ export function getRateLimitBackoff(
const baseDelay = normalizeDelayMs(serverRetryAfterMs, 1000);

if (previous && now - previous.lastAt < RATE_LIMIT_DEDUP_WINDOW_MS) {
const backoffDelay = Math.min(
baseDelay * Math.pow(2, previous.consecutive429 - 1),
MAX_BACKOFF_MS,
);
return {
attempt: previous.consecutive429,
delayMs: Math.max(baseDelay, backoffDelay),
delayMs: previous.lastDelayMs,
isDuplicate: true,
};
}
Expand All @@ -74,16 +77,18 @@ export function getRateLimitBackoff(
? previous.consecutive429 + 1
: 1;

const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), MAX_BACKOFF_MS);
const jitteredDelay = Math.min(addBackoffJitter(backoffDelay), MAX_BACKOFF_MS);
const delayMs = Math.max(baseDelay, jitteredDelay);
Comment on lines +80 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 negative jitter silently discarded on attempt=1

for attempt=1, backoffDelay === baseDelay. if Math.random() returns a value below 0.5, jitteredDelay < baseDelay, and the Math.max floor restores it to baseDelay — so downward jitter has no effect on the first 429. the effective spread is [baseDelay, baseDelay×1.2] (one-sided upward), not the symmetric ±20% the constant implies.

this is the most common 429 case, so the anti-thundering-herd goal stated in the PR description is only half-realised for first retries. attempt=2+ are unaffected because backoffDelay >> baseDelay and symmetric jitter applies.

if the Math.max floor is intentional (to honour the server-sent Retry-After as a hard minimum), add a comment:

Suggested change
const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), MAX_BACKOFF_MS);
const jitteredDelay = Math.min(addBackoffJitter(backoffDelay), MAX_BACKOFF_MS);
const delayMs = Math.max(baseDelay, jitteredDelay);
const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), MAX_BACKOFF_MS);
const jitteredDelay = Math.min(addBackoffJitter(backoffDelay), MAX_BACKOFF_MS);
// Math.max honours server Retry-After as a hard floor; on attempt=1 this makes jitter one-sided (upward only)
const delayMs = Math.max(baseDelay, jitteredDelay);

or remove the floor to get symmetric spread:

const delayMs = Math.max(0, jitteredDelay);
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/request/rate-limit-backoff.ts
Line: 80-82

Comment:
**negative jitter silently discarded on attempt=1**

for attempt=1, `backoffDelay === baseDelay`. if `Math.random()` returns a value below `0.5`, `jitteredDelay < baseDelay`, and the `Math.max` floor restores it to `baseDelay` — so downward jitter has no effect on the first 429. the effective spread is `[baseDelay, baseDelay×1.2]` (one-sided upward), not the symmetric `±20%` the constant implies.

this is the most common 429 case, so the anti-thundering-herd goal stated in the PR description is only half-realised for first retries. attempt=2+ are unaffected because `backoffDelay >> baseDelay` and symmetric jitter applies.

if the `Math.max` floor is intentional (to honour the server-sent `Retry-After` as a hard minimum), add a comment:

```suggestion
	const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), MAX_BACKOFF_MS);
	const jitteredDelay = Math.min(addBackoffJitter(backoffDelay), MAX_BACKOFF_MS);
	// Math.max honours server Retry-After as a hard floor; on attempt=1 this makes jitter one-sided (upward only)
	const delayMs = Math.max(baseDelay, jitteredDelay);
```

or remove the floor to get symmetric spread:

```typescript
const delayMs = Math.max(0, jitteredDelay);
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

rateLimitStateByAccountQuota.set(stateKey, {
consecutive429: attempt,
lastAt: now,
quotaKey,
lastDelayMs: delayMs,
});

const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), MAX_BACKOFF_MS);
return {
attempt,
delayMs: Math.max(baseDelay, backoffDelay),
delayMs,
isDuplicate: false,
};
}
Expand Down
20 changes: 20 additions & 0 deletions test/rate-limit-backoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ describe("Rate limit backoff", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
clearRateLimitBackoffState();
vi.spyOn(Math, "random").mockReturnValue(0.5);
});

afterEach(() => {
clearRateLimitBackoffState();
vi.restoreAllMocks();
vi.useRealTimers();
});

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

it("applies jitter to new backoff windows but keeps duplicate retries stable", () => {
vi.mocked(Math.random).mockReturnValueOnce(1);
const first = getRateLimitBackoff(4, "jitter-test", 1000);
expect(first.delayMs).toBe(1200);

vi.setSystemTime(new Date(1000));
vi.mocked(Math.random).mockReturnValueOnce(0);
const duplicate = getRateLimitBackoff(4, "jitter-test", 1000);
expect(duplicate.delayMs).toBe(1200);
expect(duplicate.isDuplicate).toBe(true);

vi.setSystemTime(new Date(2500));
vi.mocked(Math.random).mockReturnValueOnce(0);
const second = getRateLimitBackoff(4, "jitter-test", 1000);
expect(second.delayMs).toBe(1600);
expect(second.isDuplicate).toBe(false);
});

it("resets after quiet period", () => {
getRateLimitBackoff(0, "codex", 1000);
vi.setSystemTime(new Date(121_000));
Expand Down