The jitter matters when multiple clients (or multiple tabs) all hit the
limit simultaneously — without it they retry in lockstep and trip the
limiter again.
For workloads that walk many resources (e.g. “for each event, fetch the
benchmark”), throttle yourself rather than rely on retry. Use a simple
semaphore:
class Semaphore { private queue: Array<() => void> = []; constructor(private permits: number) {} async acquire() { if (this.permits > 0) { this.permits--; return; } await new Promise<void>(r => this.queue.push(r)); } release() { this.permits++; const next = this.queue.shift(); if (next) { this.permits--; next(); } }}const sem = new Semaphore(8); // 8 concurrent callsasync function withConcurrency<T>(fn: () => Promise<T>): Promise<T> { await sem.acquire(); try { return await fn(); } finally { sem.release(); }}// usageawait Promise.all(events.map(e => withConcurrency(() => fetchBenchmark(e.id))));
8 concurrent calls is a reasonable default. Drop to 4 if you see 429s.
Tight loops hit the limiter and don’t make the task complete any faster.
Use 2s with backoff.
Refreshing tokens reactively on every 401
A burst of failed requests creates a burst of refresh attempts. Use
single-flight refresh (see Token refresh).
Walking the full event list without throttling
for (const e of events) await fetchDetail(e.id) looks innocent but
sustained at 100ms-per-request hits the limit in 6 seconds. Use the
semaphore pattern above.
Retrying on POST/PUT with the same idempotency assumption
POST /campaigns/, POST /setup_processes/{eid} are not idempotent.
Retrying after a 429 is fine — but make sure the previous call really
didn’t succeed (check status) before re-issuing the same write.