Skip to content

Commit 533b26d

Browse files
authored
Add automatic retry policy (#115)
1 parent 9945095 commit 533b26d

File tree

3 files changed

+146
-3
lines changed

3 files changed

+146
-3
lines changed

index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const ApiError = require('./lib/error');
2+
const { withAutomaticRetries } = require('./lib/util');
23

34
const collections = require('./lib/collections');
45
const models = require('./lib/models');
@@ -201,7 +202,10 @@ class Replicate {
201202
body: data ? JSON.stringify(data) : undefined,
202203
};
203204

204-
const response = await this.fetch(url, init);
205+
const shouldRetry = method === 'GET' ?
206+
(response) => (response.status === 429 || response.status >= 500) :
207+
(response) => (response.status === 429);
208+
const response = await withAutomaticRetries(async () => this.fetch(url, init), { shouldRetry });
205209

206210
if (!response.ok) {
207211
const request = new Request(url, init);

index.test.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,44 @@ describe('Replicate client', () => {
196196
expect((error as ApiError).message).toContain("Invalid input")
197197
}
198198
})
199-
// Add more tests for error handling, edge cases, etc.
199+
200+
test('Automatically retries on 429', async () => {
201+
nock(BASE_URL)
202+
.post('/predictions')
203+
.reply(429, {
204+
detail: "Too many requests",
205+
}, { "Content-Type": "application/json", "Retry-After": "1" })
206+
.post('/predictions')
207+
.reply(201, {
208+
id: 'ufawqhfynnddngldkgtslldrkq',
209+
});
210+
const prediction = await client.predictions.create({
211+
version:
212+
'5c7d5dc6dd8bf75c1acaa8565735e7986bc5b66206b55cca93cb72c9bf15ccaa',
213+
input: {
214+
text: 'Alice',
215+
},
216+
});
217+
expect(prediction.id).toBe('ufawqhfynnddngldkgtslldrkq');
218+
});
219+
220+
test('Does not automatically retry on 500', async () => {
221+
nock(BASE_URL)
222+
.post('/predictions')
223+
.reply(500, {
224+
detail: "Internal server error",
225+
}, { "Content-Type": "application/json" });
226+
227+
await expect(
228+
client.predictions.create({
229+
version:
230+
'5c7d5dc6dd8bf75c1acaa8565735e7986bc5b66206b55cca93cb72c9bf15ccaa',
231+
input: {
232+
text: 'Alice',
233+
},
234+
})
235+
).rejects.toThrow(`Request to https://api.replicate.com/v1/predictions failed with status 500 Internal Server Error: {"detail":"Internal server error"}.`)
236+
});
200237
});
201238

202239
describe('predictions.get', () => {
@@ -234,7 +271,40 @@ describe('Replicate client', () => {
234271
);
235272
expect(prediction.id).toBe('rrr4z55ocneqzikepnug6xezpe');
236273
});
237-
// Add more tests for error handling, edge cases, etc.
274+
275+
test('Automatically retries on 429', async () => {
276+
nock(BASE_URL)
277+
.get('/predictions/rrr4z55ocneqzikepnug6xezpe')
278+
.reply(429, {
279+
detail: "Too many requests",
280+
}, { "Content-Type": "application/json", "Retry-After": "1" })
281+
.get('/predictions/rrr4z55ocneqzikepnug6xezpe')
282+
.reply(200, {
283+
id: 'rrr4z55ocneqzikepnug6xezpe',
284+
});
285+
286+
const prediction = await client.predictions.get(
287+
'rrr4z55ocneqzikepnug6xezpe'
288+
);
289+
expect(prediction.id).toBe('rrr4z55ocneqzikepnug6xezpe');
290+
});
291+
292+
test('Automatically retries on 500', async () => {
293+
nock(BASE_URL)
294+
.get('/predictions/rrr4z55ocneqzikepnug6xezpe')
295+
.reply(500, {
296+
detail: "Internal server error",
297+
}, { "Content-Type": "application/json" })
298+
.get('/predictions/rrr4z55ocneqzikepnug6xezpe')
299+
.reply(200, {
300+
id: 'rrr4z55ocneqzikepnug6xezpe',
301+
});
302+
303+
const prediction = await client.predictions.get(
304+
'rrr4z55ocneqzikepnug6xezpe'
305+
);
306+
expect(prediction.id).toBe('rrr4z55ocneqzikepnug6xezpe');
307+
});
238308
});
239309

240310
describe('predictions.cancel', () => {

lib/util.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
const ApiError = require('./error');
2+
3+
/**
4+
* Automatically retry a request if it fails with an appropriate status code.
5+
*
6+
* A GET request is retried if it fails with a 429 or 5xx status code.
7+
* A non-GET request is retried only if it fails with a 429 status code.
8+
*
9+
* If the response sets a Retry-After header,
10+
* the request is retried after the number of seconds specified in the header.
11+
* Otherwise, the request is retried after the specified interval,
12+
* with exponential backoff and jitter.
13+
*
14+
* @param {Function} request - A function that returns a Promise that resolves with a Response object
15+
* @param {object} options
16+
* @param {Function} [options.shouldRetry] - A function that returns true if the request should be retried
17+
* @param {number} [options.maxRetries] - Maximum number of retries. Defaults to 5
18+
* @param {number} [options.interval] - Interval between retries in milliseconds. Defaults to 500
19+
* @returns {Promise<Response>} - Resolves with the response object
20+
* @throws {ApiError} If the request failed
21+
*/
22+
async function withAutomaticRetries(request, options = {}) {
23+
const shouldRetry = options.shouldRetry || (() => (false));
24+
const maxRetries = options.maxRetries || 5;
25+
const interval = options.interval || 500;
26+
const jitter = options.jitter || 100;
27+
28+
// eslint-disable-next-line no-promise-executor-return
29+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
30+
31+
let attempts = 0;
32+
do {
33+
let delay = (interval * (2 ** attempts)) + (Math.random() * jitter);
34+
35+
/* eslint-disable no-await-in-loop */
36+
try {
37+
const response = await request();
38+
if (response.ok || !shouldRetry(response)) {
39+
return response;
40+
}
41+
} catch (error) {
42+
if (error instanceof ApiError) {
43+
const retryAfter = error.response.headers.get('Retry-After');
44+
if (retryAfter) {
45+
if (!Number.isInteger(retryAfter)) { // Retry-After is a date
46+
const date = new Date(retryAfter);
47+
if (!Number.isNaN(date.getTime())) {
48+
delay = date.getTime() - new Date().getTime();
49+
}
50+
} else { // Retry-After is a number of seconds
51+
delay = retryAfter * 1000;
52+
}
53+
}
54+
}
55+
}
56+
57+
if (Number.isInteger(maxRetries) && maxRetries > 0) {
58+
if (Number.isInteger(delay) && delay > 0) {
59+
await sleep(interval * 2 ** (options.maxRetries - maxRetries));
60+
}
61+
attempts += 1;
62+
}
63+
/* eslint-enable no-await-in-loop */
64+
} while (attempts < maxRetries);
65+
66+
return request();
67+
}
68+
69+
module.exports = { withAutomaticRetries };

0 commit comments

Comments
 (0)