Skip to content

Commit fb335c6

Browse files
FreekBesclaude
andauthored
feat: auto retry rate limit (#53)
* feat: automatically retry 429 and 5xx responses Bake rate-limit (429) and server-error (5xx) retry/back-off into the request layer so callers no longer need their own retry loops. - 429s are retried indefinitely, respecting the Retry-After header (falling back to retryAfterFallback seconds when absent). Applies to all methods, since a 429 is rejected before processing. - 5xx errors are retried up to maxServerErrorRetries times (default 5) for GET only, since writes may not be idempotent. The last Response is returned once retries are exhausted, keeping the existing return contract intact. - Every retry is re-scheduled through the limiter, with random jitter added to each wait. - Configurable (and disablable) via the new `retry` setting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bf65101 commit fb335c6

3 files changed

Lines changed: 239 additions & 36 deletions

File tree

README.md

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Features:
77
- Automatically determines the rate limit of your API key.
88
- Queues requests (using bottleneck)
99
- Multi-key support (be carefull, it might be too fast! 🚀)
10+
- Automatic retries on rate-limit (429) and server (5xx) errors, with back-off.
1011
- Convenience: fetch all pages from an endpoint with a single method call!
1112
- Clustering (v2.1 and up): using Redis you can run multiple instances on the same API keys!
1213

@@ -22,6 +23,7 @@ interface Fast42Settings {
2223
jobExpiration?: number; // default is 60000ms, especially important when using redis to kill infinite jobs
2324
redisConfig?: RedisConfig; // config to connect to redis, see below
2425
scopes?: string[]; // default is ['public', 'projects']
26+
retry?: RetryConfig; // automatic retry behaviour, see below
2527
}
2628

2729
interface ApiSecret {
@@ -33,6 +35,13 @@ interface RedisConfig {
3335
port: number;
3436
password?: string;
3537
}
38+
interface RetryConfig {
39+
enabled?: boolean; // master switch, default is true
40+
maxServerErrorRetries?: number; // max retries on 5xx errors per request, default is 5. 429s are always retried indefinitely.
41+
serverErrorBackoff?: number; // base ms to wait before retrying a 5xx error, default is 30000
42+
retryAfterFallback?: number; // seconds to wait on a 429 when no Retry-After header is present, default is 1
43+
jitter?: number; // max random extra ms added to every retry wait, default is 10000
44+
}
3645

3746
// Always call .init() first after constructing Fast42!
3847
init(): Promise<Fast42>
@@ -89,6 +98,29 @@ const pages = await api.getAllPages(`/campus/${campus_id}/users`, {
8998

9099
Obviously your id/secret should come from the environment and not be committed to git. (I recommend using a `.env` file and the `dotenv` package)
91100

101+
### Retries
102+
103+
The rate limiter does its best to stay under your key's limits, but the 42 API counts requests on its own clock, so an occasional `429 Too Many Requests` can still slip through (especially when running multiple instances). Fast42 therefore retries automatically:
104+
105+
- **429 (rate limit):** retried indefinitely, waiting for the duration of the `Retry-After` header (or `retryAfterFallback` seconds when it is absent). Applies to every request, including writes — a 429 is rejected before processing, so it is always safe to retry.
106+
- **5xx (server error):** retried up to `maxServerErrorRetries` times (default 5), waiting `serverErrorBackoff` ms between attempts. Only `GET` requests are retried on 5xx, since writes (`post`/`put`/`patch`/`delete`) may not be idempotent. After the retries are exhausted the last `Response` is returned, so existing `.ok`/`.status` checks keep working.
107+
108+
Every retry goes back through the rate limiter, and a random `jitter` (up to 10s by default) is added to each wait to spread retries out. Retries can be tuned or disabled via the `retry` setting:
109+
110+
```ts
111+
const api = await new Fast42([{ client_id, client_secret }], {
112+
retry: {
113+
enabled: true, // set to false to get the raw Response back without retrying
114+
maxServerErrorRetries: 3,
115+
serverErrorBackoff: 30000,
116+
retryAfterFallback: 1,
117+
jitter: 10000,
118+
},
119+
}).init();
120+
```
121+
122+
This means your own code generally no longer needs a 429/5xx retry loop around `get`/`getAllPages`.
123+
92124
How I use it:
93125

94126
```ts
@@ -123,17 +155,12 @@ async function getAll42(
123155

124156
// Attach a callback function to be called when the page promise resolves
125157
return Promise.all(pages.map(async (page) => {
126-
let p = await page;
158+
const p = await page;
127159
const pagenr = getPageNumberFromUrl(p.url);
128-
// retry when the ratelimit was hit
129-
// (this can happen because the timing on 42api side is different from the timing of the Fast42 ratelimiter)
130-
if (p.status === 429) {
131-
if (pagenr) {
132-
p = await api.getPage(url, pagenr, options);
133-
} else {
134-
console.error(`Failed retry on unkown page for ${url}`);
135-
}
136-
}
160+
// No manual 429 retry needed: Fast42 retries rate-limited (429) requests
161+
// automatically, so any page we get here has already passed the rate limiter.
162+
// (This used to be required because the timing on the 42api side differs from
163+
// the timing of the Fast42 ratelimiter.)
137164
if (p.ok) {
138165
console.log(`Recieved ${url} page: ${pagenr}`);
139166
return callback(p);

src/index.ts

Lines changed: 88 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,29 @@ interface RedisConfig {
4040
password?: string;
4141
}
4242

43+
interface RetryConfig {
44+
/** Master switch for automatic retries. Default: true. */
45+
enabled?: boolean;
46+
/**
47+
* Maximum number of retries for 5xx server errors (per request).
48+
* 429 rate-limit responses are always retried, indefinitely, respecting Retry-After.
49+
* Default: 5.
50+
*/
51+
maxServerErrorRetries?: number;
52+
/** Base delay in ms to wait before retrying a 5xx server error. Default: 30000. */
53+
serverErrorBackoff?: number;
54+
/** Delay in seconds to wait on a 429 when the Retry-After header is missing. Default: 1. */
55+
retryAfterFallback?: number;
56+
/** Maximum random extra delay in ms added to every retry wait (spreads out retries). Default: 10000. */
57+
jitter?: number;
58+
}
59+
4360
interface Fast42Settings {
4461
concurrentOffset?: number;
4562
jobExpiration?: number;
4663
redisConfig?: RedisConfig;
4764
scopes?: string[];
65+
retry?: RetryConfig;
4866
}
4967

5068
enum Method {
@@ -67,6 +85,7 @@ class Fast42 {
6785
private _redisConfig: RedisConfig | undefined;
6886
private _jobExpiration: number;
6987
private _scopes: string[];
88+
private _retry: Required<RetryConfig>;
7089

7190
/**
7291
* Constructs the api42 class
@@ -108,6 +127,13 @@ class Fast42 {
108127
this._redisConfig = settings.redisConfig
109128
this._jobExpiration = settings.jobExpiration ?? 60000
110129
this._scopes = settings.scopes ?? ['public', 'projects']
130+
this._retry = {
131+
enabled: settings.retry?.enabled ?? true,
132+
maxServerErrorRetries: settings.retry?.maxServerErrorRetries ?? 5,
133+
serverErrorBackoff: settings.retry?.serverErrorBackoff ?? 30000,
134+
retryAfterFallback: settings.retry?.retryAfterFallback ?? 1,
135+
jitter: settings.retry?.jitter ?? 10000,
136+
}
111137
}
112138

113139
/*
@@ -285,35 +311,71 @@ class Fast42 {
285311

286312

287313
private async apiReq(method: Method.GET, limiterPair: LimiterPair, url: string): Promise<Response> {
288-
const accessToken = await this.retrieveToken(limiterPair.tokenIndex)
289-
const response = limiterPair.limiter.schedule(
290-
limiterPair.jobOptions,
291-
(accessToken, url) => {
292-
return fetch(url, {
293-
method: method,
294-
headers: {
295-
Authorization: `Bearer ${accessToken.access_token}`
296-
}
297-
})
298-
}, accessToken, url)
299-
return response
314+
// GET requests are idempotent, so it is safe to retry them on server errors as well.
315+
return this.scheduleWithRetry(limiterPair, true, async () => {
316+
const accessToken = await this.retrieveToken(limiterPair.tokenIndex)
317+
return fetch(url, {
318+
method: method,
319+
headers: {
320+
Authorization: `Bearer ${accessToken.access_token}`
321+
}
322+
})
323+
})
300324
}
301325

302326
private async apiReqWithBody(method: Method.PATCH | Method.POST | Method.PUT | Method.DELETE, limiterPair: LimiterPair, url: string, body: any): Promise<Response> {
303-
const accessToken = await this.retrieveToken(limiterPair.tokenIndex)
304-
const response = limiterPair.limiter.schedule(
305-
limiterPair.jobOptions,
306-
(accessToken, url, body) => {
307-
return fetch(url, {
308-
method: method,
309-
headers: {
310-
'Authorization': `Bearer ${accessToken.access_token}`,
311-
'Content-Type': 'application/json',
312-
},
313-
body: JSON.stringify(body),
314-
})
315-
}, accessToken, url, body)
316-
return response
327+
// Writes may not be idempotent, so we only retry rate-limit (429) responses (which are
328+
// rejected before processing and therefore safe to retry), not 5xx server errors.
329+
return this.scheduleWithRetry(limiterPair, false, async () => {
330+
const accessToken = await this.retrieveToken(limiterPair.tokenIndex)
331+
return fetch(url, {
332+
method: method,
333+
headers: {
334+
'Authorization': `Bearer ${accessToken.access_token}`,
335+
'Content-Type': 'application/json',
336+
},
337+
body: JSON.stringify(body),
338+
})
339+
})
340+
}
341+
342+
/**
343+
* Schedules a request on the given limiter and transparently retries it on rate-limit (429)
344+
* and, optionally, server-error (5xx) responses. Each (re)try goes through the limiter again,
345+
* so the rate limiter keeps accounting for retried requests.
346+
*
347+
* 429 responses are retried indefinitely, waiting for the duration of the Retry-After header
348+
* (falling back to retryAfterFallback seconds when absent). 5xx responses are retried up to
349+
* maxServerErrorRetries times when retryServerErrors is true. After exhausting the 5xx retries,
350+
* the last response is returned so the caller can inspect it (the return contract is unchanged).
351+
*/
352+
private async scheduleWithRetry(limiterPair: LimiterPair, retryServerErrors: boolean, job: () => Promise<Response>): Promise<Response> {
353+
let serverErrorRetries = 0
354+
while (true) {
355+
const response: Response = await limiterPair.limiter.schedule(limiterPair.jobOptions, job)
356+
357+
if (this._retry.enabled && response.status === 429) {
358+
const retryAfterHeader = parseInt(response.headers.get('retry-after') ?? '')
359+
const retryAfter = Number.isNaN(retryAfterHeader) ? this._retry.retryAfterFallback : retryAfterHeader
360+
await this.delay(retryAfter * 1000 + Math.random() * this._retry.jitter)
361+
continue
362+
}
363+
364+
if (this._retry.enabled && retryServerErrors && response.status >= 500 && response.status < 600) {
365+
if (serverErrorRetries >= this._retry.maxServerErrorRetries) {
366+
return response
367+
}
368+
serverErrorRetries++
369+
await this.delay(this._retry.serverErrorBackoff + Math.random() * this._retry.jitter)
370+
continue
371+
}
372+
373+
return response
374+
}
375+
}
376+
377+
private delay(ms: number): Promise<void> {
378+
return new Promise((resolve) => setTimeout(resolve, ms))
317379
}
318380

319381
private parseOptions(options: { [key: string]: string } | undefined): string {

tests/index.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,120 @@ it("Should use configured scopes when requesting access token", async () => {
9393
);
9494
})
9595

96+
describe("scheduleWithRetry", () => {
97+
// A fake limiter that just runs the scheduled job immediately, so we can test the
98+
// retry logic without spinning up a real Bottleneck instance or hitting the network.
99+
const fakeLimiterPair = (job: () => Promise<any>) => ({
100+
appId: 1,
101+
limiter: { schedule: (_opts: any, fn: () => Promise<any>) => fn() },
102+
secret: { client_id, client_secret },
103+
tokenIndex: 0,
104+
jobOptions: {},
105+
_job: job,
106+
});
107+
108+
// Minimal stand-in for a node-fetch Response.
109+
const res = (status: number, retryAfter?: string) => ({
110+
status,
111+
ok: status >= 200 && status < 300,
112+
headers: { get: (h: string) => (h.toLowerCase() === 'retry-after' ? (retryAfter ?? null) : null) },
113+
});
114+
115+
// No real waiting between retries.
116+
const noWaitRetry = { serverErrorBackoff: 0, retryAfterFallback: 0, jitter: 0, maxServerErrorRetries: 2 };
117+
118+
const newApi = (retry: any = noWaitRetry) =>
119+
new Fast42([{ client_id, client_secret }], { retry });
120+
121+
it("retries a 429 until it succeeds, respecting Retry-After", async () => {
122+
const responses = [res(429, '0'), res(429, '0'), res(200)];
123+
const job = jest.fn(async () => responses.shift());
124+
const pair = fakeLimiterPair(job);
125+
126+
const result = await (newApi() as any).scheduleWithRetry(pair, true, job);
127+
128+
expect(result.status).toBe(200);
129+
expect(job).toHaveBeenCalledTimes(3);
130+
});
131+
132+
it("retries 5xx errors up to maxServerErrorRetries then returns the last response", async () => {
133+
const job = jest.fn(async () => res(503));
134+
const pair = fakeLimiterPair(job);
135+
136+
const result = await (newApi() as any).scheduleWithRetry(pair, true, job);
137+
138+
expect(result.status).toBe(503);
139+
expect(job).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
140+
});
141+
142+
it("does not retry 5xx errors when retryServerErrors is false (e.g. writes)", async () => {
143+
const job = jest.fn(async () => res(503));
144+
const pair = fakeLimiterPair(job);
145+
146+
const result = await (newApi() as any).scheduleWithRetry(pair, false, job);
147+
148+
expect(result.status).toBe(503);
149+
expect(job).toHaveBeenCalledTimes(1);
150+
});
151+
152+
it("still retries 429 for writes (retryServerErrors false)", async () => {
153+
const responses = [res(429, '0'), res(201)];
154+
const job = jest.fn(async () => responses.shift());
155+
const pair = fakeLimiterPair(job);
156+
157+
const result = await (newApi() as any).scheduleWithRetry(pair, false, job);
158+
159+
expect(result.status).toBe(201);
160+
expect(job).toHaveBeenCalledTimes(2);
161+
});
162+
163+
it("does not retry when retries are disabled", async () => {
164+
const job = jest.fn(async () => res(429, '0'));
165+
const pair = fakeLimiterPair(job);
166+
167+
const result = await (newApi({ enabled: false }) as any).scheduleWithRetry(pair, true, job);
168+
169+
expect(result.status).toBe(429);
170+
expect(job).toHaveBeenCalledTimes(1);
171+
});
172+
173+
it("returns 2xx responses immediately without retrying", async () => {
174+
const job = jest.fn(async () => res(200));
175+
const pair = fakeLimiterPair(job);
176+
177+
const result = await (newApi() as any).scheduleWithRetry(pair, true, job);
178+
179+
expect(result.status).toBe(200);
180+
expect(job).toHaveBeenCalledTimes(1);
181+
});
182+
183+
it("returns non-retryable 4xx responses immediately", async () => {
184+
const job = jest.fn(async () => res(404));
185+
const pair = fakeLimiterPair(job);
186+
187+
const result = await (newApi() as any).scheduleWithRetry(pair, true, job);
188+
189+
expect(result.status).toBe(404);
190+
expect(job).toHaveBeenCalledTimes(1);
191+
});
192+
});
193+
194+
it("Should default retry settings to enabled with bounded 5xx retries", () => {
195+
const api = new Fast42([
196+
{
197+
client_id: client_id,
198+
client_secret: client_secret,
199+
},
200+
]);
201+
expect((api as any)._retry).toEqual({
202+
enabled: true,
203+
maxServerErrorRetries: 5,
204+
serverErrorBackoff: 30000,
205+
retryAfterFallback: 1,
206+
jitter: 10000,
207+
});
208+
})
209+
96210
// it("initializes using real keys", async () => {
97211
// const api = await (new Fast42([
98212
// {

0 commit comments

Comments
 (0)