Skip to content

Commit f76273d

Browse files
committed
Add jitter to the rate-limit retry
A burst of concurrent calls hitting the same 429 (e.g. a static build rendering pages in parallel with the same token) used to all read the same X-RateLimit-Reset and retry on the exact same tick, immediately re-triggering the same rate limit. Retries now add a random extra on top of the required wait (capped at 5s, since a few seconds of spread is enough regardless of how long the base wait already is). Claude-Session: https://claude.ai/code/session_01PY2yju9C9qdHdroj3zhbvc
1 parent 383c7ad commit f76273d

3 files changed

Lines changed: 59 additions & 1 deletion

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@datocms/cda-client': patch
3+
---
4+
5+
Add jitter to the rate-limit retry
6+
7+
Many concurrent requests hitting a 429 at once (e.g. a static build rendering
8+
pages in parallel) used to all retry on the exact same tick, immediately
9+
re-triggering the same rate limit. Retries are now spread out with a random
10+
extra wait on top of the required one.

src/__test__/withJitter.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { withJitter } from '../executeQuery.js';
3+
4+
describe('withJitter()', () => {
5+
it('never returns less than the base wait', () => {
6+
vi.spyOn(Math, 'random').mockReturnValue(0);
7+
expect(withJitter(2)).toBe(2);
8+
expect(withJitter(45)).toBe(45);
9+
vi.restoreAllMocks();
10+
});
11+
12+
it('doubles a base wait smaller than the cap', () => {
13+
vi.spyOn(Math, 'random').mockReturnValue(1);
14+
expect(withJitter(2)).toBe(4);
15+
vi.restoreAllMocks();
16+
});
17+
18+
it('caps the extra wait for a base larger than the cap', () => {
19+
vi.spyOn(Math, 'random').mockReturnValue(1);
20+
expect(withJitter(45)).toBe(50);
21+
vi.restoreAllMocks();
22+
});
23+
});

src/executeQuery.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export async function rawExecuteQuery<Result, Variables>(
122122
? Number.parseInt(rateLimitReset, 10)
123123
: retryCount;
124124

125-
await wait(waitTimeInSecs * 1000);
125+
await wait(withJitter(waitTimeInSecs) * 1000);
126126

127127
return rawExecuteQuery<Result, Variables>(serializedQuery, {
128128
...options,
@@ -183,3 +183,28 @@ function wait(time: number) {
183183
setTimeout(resolve, time);
184184
});
185185
}
186+
187+
/**
188+
* The CDA enforces two per-token buckets — 40 req/s and 1000 req/min — and
189+
* `X-RateLimit-Reset` tells us how many seconds are left until whichever
190+
* bucket we hit refills (so anywhere from ~1s to ~60s). A frontend build
191+
* commonly fires many requests with the *same* token in parallel (e.g. a
192+
* static site generator rendering pages concurrently), so a burst that trips
193+
* the limit trips it for all of them together, and without jitter they'd all
194+
* wake up and retry on the same tick, reproducing the exact burst that got
195+
* them rate-limited.
196+
*
197+
* We never wait less than `baseSeconds` — retrying before the bucket refills
198+
* is guaranteed to 429 again. On top of that we add a random extra, capped at
199+
* `JITTER_CAP_SECONDS`: what desynchronizes concurrent retries is a few
200+
* seconds of spread, not a delay proportional to the wait itself — doubling
201+
* a 1s wait is fine, but doubling the ~60s wait of the per-minute bucket
202+
* would needlessly leave callers waiting up to a minute longer than needed.
203+
*/
204+
const JITTER_CAP_SECONDS = 5;
205+
206+
export function withJitter(baseSeconds: number): number {
207+
return (
208+
baseSeconds + Math.random() * Math.min(baseSeconds, JITTER_CAP_SECONDS)
209+
);
210+
}

0 commit comments

Comments
 (0)