Skip to content

Commit 6931efd

Browse files
author
Harry Stevens
committed
feat: add retry support with exponential backoff
1 parent 4f13abf commit 6931efd

3 files changed

Lines changed: 152 additions & 7 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ await browser.close();
6464

6565
Works with `html`, `xml`, `csv`, `json`, and `text`. When `browser` is not set, native `fetch` is used as before.
6666

67+
### Retries
68+
69+
Automatically retry failed requests (non-2xx responses or network errors) with exponential backoff:
70+
71+
```js
72+
const $ = await html("https://example.com", {
73+
retries: 3,
74+
retryDelay: 1000,
75+
});
76+
```
77+
78+
- `retries` — number of retry attempts after the initial failure (default `0`)
79+
- `retryDelay` — base delay in milliseconds (default `1000`). Each subsequent retry doubles the delay (1s, 2s, 4s, …).
80+
81+
Works with `html`, `xml`, `csv`, `json`, and `text`, and can be combined with the `browser` option.
82+
6783
### Download a file
6884

6985
```js

src/requester.js

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,27 @@ import randomAgent from "./utils/agent.js";
22
import { loadPlaywright } from "./browser.js";
33

44
export default async function requester(parser, url, options = {}) {
5-
const body =
6-
options.browser === true
7-
? await browserFetchOnce(url)
8-
: options.browser
9-
? await browserFetchReuse(url, options.browser)
10-
: await nativeFetch(url);
11-
return parser(body);
5+
const { retries = 0, retryDelay = 1000 } = options;
6+
7+
let lastError;
8+
for (let attempt = 0; attempt <= retries; attempt++) {
9+
try {
10+
const body =
11+
options.browser === true
12+
? await browserFetchOnce(url)
13+
: options.browser
14+
? await browserFetchReuse(url, options.browser)
15+
: await nativeFetch(url);
16+
return parser(body);
17+
} catch (error) {
18+
lastError = error;
19+
if (attempt < retries) {
20+
const delay = retryDelay * 2 ** attempt;
21+
await new Promise((resolve) => setTimeout(resolve, delay));
22+
}
23+
}
24+
}
25+
throw lastError;
1226
}
1327

1428
async function nativeFetch(url) {

test/retry-test.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import assert from "node:assert";
2+
import requester from "../src/requester.js";
3+
4+
const identity = (t) => t;
5+
6+
function mockFetch(failCount, { networkError = false } = {}) {
7+
let calls = 0;
8+
const originalFetch = globalThis.fetch;
9+
globalThis.fetch = async (_url) => {
10+
calls++;
11+
if (calls <= failCount) {
12+
if (networkError) throw new Error("Network error");
13+
return {
14+
ok: false,
15+
status: 500,
16+
text: async () => "Internal Server Error",
17+
};
18+
}
19+
return { ok: true, status: 200, text: async () => "success" };
20+
};
21+
return {
22+
get calls() {
23+
return calls;
24+
},
25+
restore() {
26+
globalThis.fetch = originalFetch;
27+
},
28+
};
29+
}
30+
31+
// Succeeds on first try without retries
32+
{
33+
const mock = mockFetch(0);
34+
const result = await requester(identity, "http://example.com");
35+
assert.strictEqual(result, "success");
36+
assert.strictEqual(mock.calls, 1);
37+
mock.restore();
38+
console.log("PASS: succeeds on first try");
39+
}
40+
41+
// Fails once, retries and succeeds
42+
{
43+
const mock = mockFetch(1);
44+
const result = await requester(identity, "http://example.com", {
45+
retries: 2,
46+
retryDelay: 10,
47+
});
48+
assert.strictEqual(result, "success");
49+
assert.strictEqual(mock.calls, 2);
50+
mock.restore();
51+
console.log("PASS: retries on failure and succeeds");
52+
}
53+
54+
// Exhausts all retries then throws
55+
{
56+
const mock = mockFetch(5);
57+
await assert.rejects(
58+
() =>
59+
requester(identity, "http://example.com", { retries: 2, retryDelay: 10 }),
60+
/Request failed/,
61+
);
62+
assert.strictEqual(mock.calls, 3);
63+
mock.restore();
64+
console.log("PASS: exhausts retries and throws");
65+
}
66+
67+
// Retries on network error
68+
{
69+
const mock = mockFetch(1, { networkError: true });
70+
const result = await requester(identity, "http://example.com", {
71+
retries: 1,
72+
retryDelay: 10,
73+
});
74+
assert.strictEqual(result, "success");
75+
assert.strictEqual(mock.calls, 2);
76+
mock.restore();
77+
console.log("PASS: retries on network error");
78+
}
79+
80+
// Default retries is 0 — no automatic retry
81+
{
82+
const mock = mockFetch(1);
83+
await assert.rejects(
84+
() => requester(identity, "http://example.com"),
85+
/Request failed/,
86+
);
87+
assert.strictEqual(mock.calls, 1);
88+
mock.restore();
89+
console.log("PASS: default retries is 0");
90+
}
91+
92+
// Exponential backoff increases delay between attempts
93+
{
94+
const mock = mockFetch(4);
95+
const delays = [];
96+
const originalSetTimeout = globalThis.setTimeout;
97+
globalThis.setTimeout = (fn, ms) => {
98+
delays.push(ms);
99+
return originalSetTimeout(fn, 0);
100+
};
101+
await assert.rejects(
102+
() =>
103+
requester(identity, "http://example.com", {
104+
retries: 3,
105+
retryDelay: 100,
106+
}),
107+
/Request failed/,
108+
);
109+
globalThis.setTimeout = originalSetTimeout;
110+
assert.deepStrictEqual(delays, [100, 200, 400]);
111+
mock.restore();
112+
console.log("PASS: exponential backoff delays are correct");
113+
}
114+
115+
console.log("\nAll retry tests passed!");

0 commit comments

Comments
 (0)