@boy-offi9-inc/reqkit 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -21
- package/package.json +10 -3
- package/src/_http.js +59 -0
- package/src/createFetch.js +218 -0
- package/src/createLimiter.js +55 -0
- package/src/index.js +7 -1
- package/src/index.mjs +4 -0
- package/src/retryAfterAware.js +26 -88
- package/src/withRetry.js +112 -23
- package/src/withTimeout.js +43 -15
- package/types/index.d.ts +134 -2
package/README.md
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/@boy-offi9-inc/reqkit"><img src="https://img.shields.io/npm/v/@boy-offi9-inc/reqkit?color=CB3837&logo=npm&logoColor=white" alt="npm version"></a>
|
|
9
|
+
<a href="https://github.com/boy-offi9-inc/reqkit/actions/workflows/ci.yml"><img src="https://github.com/boy-offi9-inc/reqkit/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
8
10
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
9
11
|
<img src="https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js&logoColor=white" alt="Node >=18">
|
|
10
12
|
<img src="https://img.shields.io/badge/npm-%40boy--offi9--inc%2Freqkit-CB3837?logo=npm&logoColor=white" alt="npm package">
|
|
@@ -15,27 +17,66 @@
|
|
|
15
17
|
|
|
16
18
|
# reqkit
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
**Tiny, zero-dependency HTTP helpers for `fetch`, axios, or anything else — retries that respect `Retry-After`, timeouts that really cancel, and errors that make sense.**
|
|
21
|
+
|
|
22
|
+
Not a client, not an axios replacement. Pull in the one function you need, or let [`createFetch`](#createfetchdefaults) wire them together for you — you always get a plain `Response` back.
|
|
19
23
|
|
|
20
24
|
```js
|
|
21
|
-
|
|
25
|
+
import { createFetch } from "@boy-offi9-inc/reqkit";
|
|
26
|
+
|
|
27
|
+
const api = createFetch({
|
|
28
|
+
baseUrl: "https://api.example.com/v1",
|
|
29
|
+
headers: () => ({ authorization: `Bearer ${getToken()}` }),
|
|
30
|
+
timeout: 10_000, // per attempt
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const user = await api.json("/users/1", { query: { expand: "team" } });
|
|
34
|
+
// retries network errors, timeouts and 429/5xx, waits for Retry-After,
|
|
35
|
+
// doesn't retry a POST unless you opt in, throws HTTPError on non-2xx
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or take just the pieces you want:
|
|
22
39
|
|
|
23
|
-
|
|
40
|
+
```js
|
|
41
|
+
import { createLimiter, withRetry, withTimeout } from "@boy-offi9-inc/reqkit";
|
|
42
|
+
|
|
43
|
+
const limit = createLimiter(4); // 4 downloads at a time
|
|
44
|
+
const pages = await limit.map(urls, (url) =>
|
|
45
|
+
withRetry(
|
|
46
|
+
() => withTimeout((signal) => fetch(url, { signal }), 8000),
|
|
47
|
+
{ retryStatusCodes: [429, 503] } // and it waits for Retry-After
|
|
48
|
+
)
|
|
49
|
+
);
|
|
24
50
|
```
|
|
25
51
|
|
|
26
52
|
---
|
|
27
53
|
|
|
28
|
-
##
|
|
54
|
+
## Pick what you need
|
|
55
|
+
|
|
56
|
+
| You want to… | Use |
|
|
57
|
+
|---|---|
|
|
58
|
+
| Retry on errors *and* bad statuses, honoring `Retry-After` | [`withRetry`](#withretryfn-opts) / [`retryAfterAware`](#retryafterawarefn-opts) |
|
|
59
|
+
| Time out *and actually cancel* the request | [`withTimeout`](#withtimeoutfn-ms-opts) |
|
|
60
|
+
| One error shape from fetch, axios and node (incl. `fetch failed`) | [`normalizeError`](#normalizeerrorerr) |
|
|
61
|
+
| Parse JSON without try/catch | [`parseJsonSafe`](#parsejsonsafeinput) |
|
|
62
|
+
| Build a query string from an object | [`buildQuery`](#buildqueryparams) |
|
|
63
|
+
| Download progress and size caps for any stream | [`withProgress`](#withprogressresponse-onprogress-opts) |
|
|
64
|
+
| Merge identical concurrent calls into one | [`withDedupe`](#withdedupefn-opts) |
|
|
65
|
+
| Run N things at a time | [`createLimiter`](#createlimiterconcurrency) |
|
|
66
|
+
| All of the above, wired up | [`createFetch`](#createfetchdefaults) |
|
|
67
|
+
|
|
68
|
+
---
|
|
29
69
|
|
|
30
|
-
|
|
70
|
+
## Why this exists
|
|
31
71
|
|
|
32
|
-
|
|
72
|
+
Most HTTP retry/timeout wrappers bundle everything into a client you have to fully adopt. This is the opposite: standalone functions that compose with whatever you're already using. Zero dependencies, CJS + ESM, TypeScript types included.
|
|
33
73
|
|
|
34
|
-
|
|
35
|
-
- **`withProgress`** — progress callback for any streamed response body (downloads, large payloads, anything), not tied to a specific HTTP client.
|
|
36
|
-
- **`withDedupe`** — coalesces concurrent calls with the same key into one in-flight call, so simultaneous requests for the same resource don't each hit the network.
|
|
74
|
+
**Honest note:** retries, timeouts and error normalization are a well-covered problem — there are other solid packages doing similar things (`fetchy`, `fetchpilot`, `p-retry`, to name a few). What reqkit leans on:
|
|
37
75
|
|
|
38
|
-
|
|
76
|
+
- **It reads the server.** Retries wait for `Retry-After` / `X-RateLimit-Reset` instead of blind backoff on 429s — most retry libraries treat rate-limit responses like any other failure.
|
|
77
|
+
- **It's safe by default.** `createFetch` only retries idempotent methods, so a timed-out `POST` can't double-charge anyone.
|
|
78
|
+
- **It understands real-world errors.** Node's `TypeError: fetch failed` and the browser's `Failed to fetch` are classified as network errors, and a deliberate abort is never reported as a timeout.
|
|
79
|
+
- **It's not tied to a client.** `withProgress`, `withDedupe` and `createLimiter` work with any streamed body or async function.
|
|
39
80
|
|
|
40
81
|
---
|
|
41
82
|
|
|
@@ -51,23 +92,36 @@ npm install @boy-offi9-inc/reqkit
|
|
|
51
92
|
|
|
52
93
|
### `withRetry(fn, opts?)`
|
|
53
94
|
|
|
54
|
-
Retries an async function with exponential backoff + jitter.
|
|
95
|
+
Retries an async function with exponential backoff + jitter. Retries on thrown errors, and — opt in — on *results* (HTTP status codes or any predicate), waiting for `Retry-After` / `X-RateLimit-Reset` when the result has headers.
|
|
55
96
|
|
|
56
97
|
```js
|
|
57
98
|
const data = await withRetry(
|
|
58
99
|
() => fetch(url).then(r => r.json()),
|
|
59
100
|
{ retries: 3, baseDelayMs: 300, onRetry: (err, attempt, delayMs) => console.log(`retry ${attempt} in ${delayMs}ms`) }
|
|
60
101
|
);
|
|
102
|
+
|
|
103
|
+
// fetch doesn't throw on 4xx/5xx — retry on statuses by returning the Response:
|
|
104
|
+
const res = await withRetry(() => fetch(url), { retryStatusCodes: [429, 500, 502, 503, 504] });
|
|
61
105
|
```
|
|
62
106
|
|
|
63
107
|
| Option | Default | Description |
|
|
64
108
|
|---|---|---|
|
|
65
|
-
| `retries` | `3` | max retry attempts after the first try |
|
|
109
|
+
| `retries` | `3` | max retry attempts after the first try (`Infinity` works with `maxRetryTime`) |
|
|
66
110
|
| `baseDelayMs` | `300` | initial delay |
|
|
67
|
-
| `maxDelayMs` | `10000` |
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
111
|
+
| `maxDelayMs` | `10000` | backoff ceiling |
|
|
112
|
+
| `factor` | `2` | backoff multiplier |
|
|
113
|
+
| `jitter` | `0.2` | extra random fraction on top of the delay; `0` disables; `"full"` picks uniformly in `[0, delay]` (AWS "full jitter" — best when many clients retry at once) |
|
|
114
|
+
| `maxRetryTime` | `Infinity` | total time budget across attempts and waits; once the next wait would blow it, the last error/result is surfaced |
|
|
115
|
+
| `shouldRetry` | retries anything but AbortError | `(err, attempt) => boolean` — for thrown errors |
|
|
116
|
+
| `retryStatusCodes` | `[]` | retry when `result.status` is in this list |
|
|
117
|
+
| `shouldRetryResult` | — | `(result, attempt) => boolean` — retry on any result predicate |
|
|
118
|
+
| `respectRetryAfter` | `true` | wait for `Retry-After` / `X-RateLimit-Reset` from `result.headers` |
|
|
119
|
+
| `maxRetryAfterMs` | `60000` | cap on a header-provided wait |
|
|
120
|
+
| `calculateDelay` | — | `({ attempt, error, result, delayMs }) => ms \| false` — override the wait, or `false` to stop |
|
|
121
|
+
| `onRetry` | — | `(err, attempt, delayMs, { reason, result, status }) => void` — `err` is `undefined` when a result triggered the retry |
|
|
122
|
+
| `signal` | — | `AbortSignal` — cancels the whole retry loop, including any pending backoff wait, throwing `RetryAbortedError` (its `cause` is the last failure) |
|
|
123
|
+
|
|
124
|
+
When retries run out on a *result* (e.g. a 503), the last result is returned rather than thrown — you decide what a bad status means. Bodies of retried responses are cancelled so connections are freed.
|
|
71
125
|
|
|
72
126
|
```js
|
|
73
127
|
// Cancel a long retry/backoff sequence externally — e.g. the user navigated away
|
|
@@ -76,9 +130,19 @@ const promise = withRetry(() => fetch(url), { retries: 5, signal: controller.sig
|
|
|
76
130
|
controller.abort(); // rejects with RetryAbortedError, even mid-backoff
|
|
77
131
|
```
|
|
78
132
|
|
|
79
|
-
|
|
133
|
+
**Stop early with `StopRetry`** — throw it from inside `fn` when an error is not worth retrying (bad credentials, a 400...):
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
const { withRetry, StopRetry } = require("@boy-offi9-inc/reqkit");
|
|
137
|
+
|
|
138
|
+
await withRetry(async () => {
|
|
139
|
+
const res = await fetch(url);
|
|
140
|
+
if (res.status === 401) throw new StopRetry(new Error("bad token")); // rethrown as-is, no more attempts
|
|
141
|
+
return res;
|
|
142
|
+
});
|
|
143
|
+
```
|
|
80
144
|
|
|
81
|
-
### `withTimeout(fn, ms)`
|
|
145
|
+
### `withTimeout(fn, ms, opts?)`
|
|
82
146
|
|
|
83
147
|
Races an async function against a timeout, passing it an `AbortSignal` so the underlying request can actually be cancelled.
|
|
84
148
|
|
|
@@ -90,6 +154,12 @@ const data = await withTimeout(
|
|
|
90
154
|
// throws TimeoutError on timeout, regardless of whether fn cooperates with the signal
|
|
91
155
|
```
|
|
92
156
|
|
|
157
|
+
Pass your own `signal` to cancel from the outside too — it's linked into the one `fn` receives, and aborting it rejects with its reason even if `fn` ignores the signal. Use `ms: Infinity` to disable the timer and only link the signal. A negative or non-number `ms` throws a `TypeError`.
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
await withTimeout(signal => fetch(url, { signal }), 5000, { signal: userController.signal });
|
|
161
|
+
```
|
|
162
|
+
|
|
93
163
|
### `normalizeError(err)`
|
|
94
164
|
|
|
95
165
|
One consistent error shape regardless of source (fetch, axios, node http, generic):
|
|
@@ -126,7 +196,7 @@ For full parsing/nested-object support, use [`query-string`](https://www.npmjs.c
|
|
|
126
196
|
|
|
127
197
|
### `retryAfterAware(fn, opts?)`
|
|
128
198
|
|
|
129
|
-
|
|
199
|
+
A ready-made preset over `withRetry`: retries a fetch-like call on 429/503, honoring `Retry-After`/`X-RateLimit-Reset` headers instead of blind backoff (no jitter; thrown network errors and timeouts are retried too). Use `withRetry` directly for jitter, budgets, or custom statuses.
|
|
130
200
|
|
|
131
201
|
```js
|
|
132
202
|
const response = await retryAfterAware(
|
|
@@ -148,7 +218,7 @@ const response = await retryAfterAware(
|
|
|
148
218
|
|
|
149
219
|
The body of every response that gets retried is cancelled so the connection is freed.
|
|
150
220
|
|
|
151
|
-
### `withProgress(response, onProgress?)`
|
|
221
|
+
### `withProgress(response, onProgress?, opts?)`
|
|
152
222
|
|
|
153
223
|
Reads a streamed `Response` body while reporting progress, returns the collected bytes as a `Uint8Array`.
|
|
154
224
|
|
|
@@ -193,9 +263,78 @@ const fn = withDedupe((req) => doFetch(req), { keyFn: (req) => req.url });
|
|
|
193
263
|
|
|
194
264
|
---
|
|
195
265
|
|
|
266
|
+
### `createLimiter(concurrency)`
|
|
267
|
+
|
|
268
|
+
At most `concurrency` calls run at once; the rest wait in a FIFO queue.
|
|
269
|
+
|
|
270
|
+
```js
|
|
271
|
+
const limit = createLimiter(4);
|
|
272
|
+
|
|
273
|
+
const files = await limit.map(urls, (url) => download(url)); // results keep input order
|
|
274
|
+
// or one at a time:
|
|
275
|
+
const res = await limit(() => fetch(url));
|
|
276
|
+
|
|
277
|
+
limit.activeCount; // running now
|
|
278
|
+
limit.pendingCount; // waiting
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
A task that throws or rejects only fails its own promise — the queue keeps going. `concurrency` must be an integer ≥ 1 (or `Infinity`).
|
|
282
|
+
|
|
283
|
+
### `createFetch(defaults)`
|
|
284
|
+
|
|
285
|
+
Opt-in assembly of the pieces above into a fetch-like function. It's still plain `fetch` underneath: you get a standard `Response`, and you can pass your own `fetch`.
|
|
286
|
+
|
|
287
|
+
```js
|
|
288
|
+
const { createFetch } = require("@boy-offi9-inc/reqkit");
|
|
289
|
+
|
|
290
|
+
const api = createFetch({
|
|
291
|
+
baseUrl: "https://api.example.com/v1",
|
|
292
|
+
headers: () => ({ authorization: `Bearer ${getToken()}` }), // object, or a function for changing tokens
|
|
293
|
+
timeout: 10_000, // per attempt
|
|
294
|
+
retry: { retries: 3 }, // false to disable
|
|
295
|
+
dedupe: true, // merge identical concurrent GETs
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const user = await api.json("/users/1", { query: { expand: ["team", "roles"] } });
|
|
299
|
+
const created = await api("/users", { method: "POST", json: { name: "Ada" } }); // Response
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
await api("/missing");
|
|
303
|
+
} catch (err) {
|
|
304
|
+
if (err instanceof HTTPError) console.log(err.status, await err.response.json());
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const admin = api.extend({ headers: { "x-role": "admin" } }); // merges over the parent
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
| Default / per-request option | Default | Description |
|
|
311
|
+
|---|---|---|
|
|
312
|
+
| `baseUrl` | — | joined onto relative URLs; absolute URLs are left alone |
|
|
313
|
+
| `headers` | — | object, `Headers`, or `() => headers` (sync or async) |
|
|
314
|
+
| `query` *(per request)* | — | object, serialized with `buildQuery` |
|
|
315
|
+
| `json` *(per request)* | — | serialized as the body, `content-type: application/json` set for you |
|
|
316
|
+
| `timeout` | `30000` | per attempt, in ms; `false` disables |
|
|
317
|
+
| `signal` *(per request)* | — | external abort; surfaces as a plain `AbortError`, never retried |
|
|
318
|
+
| `retry` | see below | `false` disables; otherwise `{ retries, statusCodes, methods, baseDelayMs, maxDelayMs, factor, jitter, maxRetryTime, shouldRetry, calculateDelay, onRetry }` |
|
|
319
|
+
| `dedupe` | `false` | merge identical concurrent `GET`/`HEAD` calls; every caller gets its own readable clone. Requests with their own `signal` are never merged. |
|
|
320
|
+
| `throwHttpErrors` | `true` | throw `HTTPError` for non-2xx (`err.status`, `err.response`, `err.url`, `err.method`) |
|
|
321
|
+
| `fetch` | `globalThis.fetch` | swap in undici, node-fetch, a test double... |
|
|
322
|
+
| `onRequest(ctx)` | — | once per call; may mutate `ctx.url` / `ctx.init` (e.g. add a header) |
|
|
323
|
+
| `onResponse(ctx)` | — | once per call, with the final response, before `HTTPError` is thrown |
|
|
324
|
+
|
|
325
|
+
**Retry defaults:** 2 retries on network errors, timeouts, and status `408, 425, 429, 500, 502, 503, 504`, honoring `Retry-After`. Only **idempotent methods** (`GET, HEAD, OPTIONS, PUT, DELETE`) are retried — `POST`/`PATCH` aren't, so a timeout can't double-charge anyone. Opt a request in by sending an `Idempotency-Key` header, or by listing the method in `retry.methods`. Stream bodies are never retried (they can't be replayed).
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
196
329
|
## Design notes
|
|
197
330
|
|
|
198
331
|
- **Zero dependencies.** Nothing to audit, nothing to break underneath you.
|
|
199
332
|
- **CJS + ESM.** `require()` and `import` both work.
|
|
200
333
|
- **Composable, not a client.** Every function takes and returns plain values (`Response`, plain objects, `Uint8Array`) — nothing proprietary to learn.
|
|
201
|
-
- **TypeScript types included**, hand-written (no build step to go wrong).
|
|
334
|
+
- **TypeScript types included**, hand-written (no build step to go wrong), with separate entries for CJS and ESM.
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Prior art
|
|
339
|
+
|
|
340
|
+
reqkit's building blocks are small, from-scratch implementations of ideas that proved themselves in popular libraries — go look at them, they're great: [`p-retry`](https://github.com/sindresorhus/p-retry) (`StopRetry`, time budgets), [`ky`](https://github.com/sindresorhus/ky) and [`ofetch`](https://github.com/unjs/ofetch) (`HTTPError`, `json`/`query`/`baseUrl`, `extend`, retry statuses), [`got`](https://github.com/sindresorhus/got) and [`axios-retry`](https://github.com/softonic/axios-retry) (retry only idempotent methods, `calculateDelay`), [`p-limit`](https://github.com/sindresorhus/p-limit) (concurrency limiter), and the AWS Architecture Blog's "full jitter" backoff.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boy-offi9-inc/reqkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Small, composable HTTP
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Small, composable HTTP helpers — retry with backoff and Retry-After, timeouts, normalized errors, safe JSON, download progress, dedupe, a concurrency limiter, and an opt-in createFetch. Not a client; works alongside fetch/axios/anything.",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"types": "./types/index.d.ts",
|
|
7
7
|
"exports": {
|
|
@@ -32,7 +32,13 @@
|
|
|
32
32
|
"retry-after",
|
|
33
33
|
"download-progress",
|
|
34
34
|
"error-normalization",
|
|
35
|
-
"utility"
|
|
35
|
+
"utility",
|
|
36
|
+
"limiter",
|
|
37
|
+
"concurrency",
|
|
38
|
+
"dedupe",
|
|
39
|
+
"http-client",
|
|
40
|
+
"fetch-wrapper",
|
|
41
|
+
"abort-signal"
|
|
36
42
|
],
|
|
37
43
|
"author": "Boy Offi9",
|
|
38
44
|
"license": "MIT",
|
|
@@ -54,6 +60,7 @@
|
|
|
54
60
|
"src",
|
|
55
61
|
"types",
|
|
56
62
|
"README.md",
|
|
63
|
+
"CHANGELOG.md",
|
|
57
64
|
"LICENSE"
|
|
58
65
|
],
|
|
59
66
|
"devDependencies": {
|
package/src/_http.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a Retry-After header value into milliseconds. Per HTTP spec it's
|
|
3
|
+
* either an integer number of seconds, or an HTTP-date.
|
|
4
|
+
*/
|
|
5
|
+
function parseRetryAfter(headerValue) {
|
|
6
|
+
if (headerValue === null || headerValue === undefined) return null;
|
|
7
|
+
const value = String(headerValue).trim();
|
|
8
|
+
if (!value) return null;
|
|
9
|
+
|
|
10
|
+
// delta-seconds (spec says integer; tolerate "1.5" from sloppy servers)
|
|
11
|
+
if (/^\d+(\.\d+)?$/.test(value)) {
|
|
12
|
+
return Number(value) * 1000;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const dateMs = Date.parse(value);
|
|
16
|
+
if (!Number.isNaN(dateMs)) {
|
|
17
|
+
return Math.max(0, dateMs - Date.now());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads a wait time from common rate-limit headers, trying Retry-After first,
|
|
25
|
+
* then falling back to X-RateLimit-Reset (epoch seconds or ms) if present.
|
|
26
|
+
*/
|
|
27
|
+
function getWaitMsFromHeaders(headers) {
|
|
28
|
+
if (!headers || typeof headers.get !== "function") return null;
|
|
29
|
+
|
|
30
|
+
const retryAfter = parseRetryAfter(headers.get("retry-after"));
|
|
31
|
+
if (retryAfter !== null) return retryAfter;
|
|
32
|
+
|
|
33
|
+
const resetHeader = headers.get("x-ratelimit-reset") || headers.get("x-rate-limit-reset");
|
|
34
|
+
if (resetHeader) {
|
|
35
|
+
const resetValue = Number(resetHeader);
|
|
36
|
+
if (!Number.isNaN(resetValue)) {
|
|
37
|
+
// Could be epoch seconds or epoch ms — treat values below a
|
|
38
|
+
// year-2100-in-seconds threshold as seconds.
|
|
39
|
+
const resetMs = resetValue > 4102444800 ? resetValue : resetValue * 1000;
|
|
40
|
+
return Math.max(0, resetMs - Date.now());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function discardBody(response) {
|
|
48
|
+
// An unread body keeps the underlying connection busy; cancel it before
|
|
49
|
+
// we sit through a backoff and fire the next request.
|
|
50
|
+
try {
|
|
51
|
+
if (response.body && typeof response.body.cancel === "function" && !response.bodyUsed) {
|
|
52
|
+
await response.body.cancel();
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// locked / already consumed / not a web stream — safe to ignore
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { parseRetryAfter, getWaitMsFromHeaders, discardBody };
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
const { withRetry, RetryAbortedError } = require("./withRetry");
|
|
2
|
+
const { withTimeout } = require("./withTimeout");
|
|
3
|
+
const { withDedupe } = require("./withDedupe");
|
|
4
|
+
const { buildQuery } = require("./buildQuery");
|
|
5
|
+
const { parseJsonSafe } = require("./parseJsonSafe");
|
|
6
|
+
const { normalizeError } = require("./normalizeError");
|
|
7
|
+
|
|
8
|
+
const DEFAULT_RETRY_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504];
|
|
9
|
+
// Safe to repeat without side effects. POST/PATCH are deliberately absent.
|
|
10
|
+
const DEFAULT_RETRY_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"];
|
|
11
|
+
|
|
12
|
+
class HTTPError extends Error {
|
|
13
|
+
constructor(response, request) {
|
|
14
|
+
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
15
|
+
super(`Request failed with status ${response.status}${statusText}: ${request.method} ${request.url}`);
|
|
16
|
+
this.name = "HTTPError";
|
|
17
|
+
this.status = response.status;
|
|
18
|
+
this.statusText = response.statusText || "";
|
|
19
|
+
this.response = response;
|
|
20
|
+
this.url = request.url;
|
|
21
|
+
this.method = request.method;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isAbsoluteUrl(url) {
|
|
26
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(url);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function joinUrl(baseUrl, url, query) {
|
|
30
|
+
let full = String(url);
|
|
31
|
+
if (baseUrl && !isAbsoluteUrl(full)) {
|
|
32
|
+
full = `${String(baseUrl).replace(/\/+$/, "")}/${full.replace(/^\/+/, "")}`;
|
|
33
|
+
}
|
|
34
|
+
const qs = query ? buildQuery(query) : "";
|
|
35
|
+
if (qs) full += (full.includes("?") ? "&" : "?") + qs;
|
|
36
|
+
return full;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function resolveHeaders(source) {
|
|
40
|
+
const value = typeof source === "function" ? await source() : source;
|
|
41
|
+
return new Headers(value || undefined);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mergeHeaders(...headerSets) {
|
|
45
|
+
const out = new Headers();
|
|
46
|
+
for (const set of headerSets) {
|
|
47
|
+
set.forEach((value, key) => out.set(key, value));
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isStreamBody(body) {
|
|
53
|
+
return Boolean(body) && typeof body === "object" && typeof body.getReader === "function";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mergeDefaults(base, extra) {
|
|
57
|
+
const headersOf = (d) => d.headers;
|
|
58
|
+
const merged = { ...base, ...extra };
|
|
59
|
+
|
|
60
|
+
if (base.headers || extra.headers) {
|
|
61
|
+
merged.headers = async () => {
|
|
62
|
+
const a = await resolveHeaders(headersOf(base));
|
|
63
|
+
const b = await resolveHeaders(headersOf(extra));
|
|
64
|
+
return mergeHeaders(a, b);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (base.retry !== false && extra.retry !== false && (base.retry || extra.retry)) {
|
|
68
|
+
merged.retry = { ...base.retry, ...extra.retry };
|
|
69
|
+
}
|
|
70
|
+
return merged;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Builds a fetch-like function from the other reqkit pieces — only if you
|
|
75
|
+
* want that. It's still plain `fetch` underneath: you get a standard
|
|
76
|
+
* `Response` back, and `fetch` itself stays swappable.
|
|
77
|
+
*
|
|
78
|
+
* What you get on top of fetch:
|
|
79
|
+
* - `baseUrl`, `query` (via buildQuery), `json` request bodies
|
|
80
|
+
* - default headers (an object, or a function for tokens that change)
|
|
81
|
+
* - per-attempt `timeout` (withTimeout) and external `signal`
|
|
82
|
+
* - retries (withRetry) on network errors/timeouts and retryable statuses,
|
|
83
|
+
* honoring Retry-After; only for idempotent methods unless you opt in
|
|
84
|
+
* - concurrent GET de-duplication (withDedupe)
|
|
85
|
+
* - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
|
|
86
|
+
* - `onRequest` / `onResponse` hooks
|
|
87
|
+
*
|
|
88
|
+
* @param {object} [defaults]
|
|
89
|
+
* @param {string} [defaults.baseUrl]
|
|
90
|
+
* @param {HeadersInit | (() => HeadersInit | Promise<HeadersInit>)} [defaults.headers]
|
|
91
|
+
* @param {number | false} [defaults.timeout=30000] - per attempt, ms
|
|
92
|
+
* @param {false | object} [defaults.retry] - false disables. Options:
|
|
93
|
+
* `retries`(2), `statusCodes`, `methods`, `baseDelayMs`, `maxDelayMs`,
|
|
94
|
+
* `factor`, `jitter`, `maxRetryTime`, `onRetry`, `calculateDelay`
|
|
95
|
+
* @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
|
|
96
|
+
* @param {boolean} [defaults.throwHttpErrors=true]
|
|
97
|
+
* @param {typeof fetch} [defaults.fetch=globalThis.fetch]
|
|
98
|
+
* @param {(ctx: {url: string, init: object}) => void | Promise<void>} [defaults.onRequest] -
|
|
99
|
+
* runs once per call; may mutate ctx.url / ctx.init (e.g. add a header)
|
|
100
|
+
* @param {(ctx: {url: string, init: object, response: Response}) => void | Promise<void>} [defaults.onResponse]
|
|
101
|
+
*/
|
|
102
|
+
function createFetch(defaults = {}) {
|
|
103
|
+
const {
|
|
104
|
+
baseUrl,
|
|
105
|
+
headers: defaultHeaders,
|
|
106
|
+
timeout: defaultTimeout = 30000,
|
|
107
|
+
retry: defaultRetry = {},
|
|
108
|
+
dedupe = false,
|
|
109
|
+
throwHttpErrors: defaultThrow = true,
|
|
110
|
+
fetch: customFetch,
|
|
111
|
+
onRequest,
|
|
112
|
+
onResponse,
|
|
113
|
+
} = defaults;
|
|
114
|
+
|
|
115
|
+
const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
|
|
116
|
+
|
|
117
|
+
// Shared in-flight GETs. Every caller gets its own clone of the response so
|
|
118
|
+
// body reads don't fight each other.
|
|
119
|
+
const sharedFetch = withDedupe((key, url, init) => doFetch(url, init), { keyFn: (key) => key });
|
|
120
|
+
|
|
121
|
+
async function request(input, init = {}) {
|
|
122
|
+
const {
|
|
123
|
+
query,
|
|
124
|
+
json,
|
|
125
|
+
timeout = defaultTimeout,
|
|
126
|
+
retry: retryOpt,
|
|
127
|
+
throwHttpErrors = defaultThrow,
|
|
128
|
+
signal,
|
|
129
|
+
headers: initHeaders,
|
|
130
|
+
...fetchInit
|
|
131
|
+
} = init;
|
|
132
|
+
|
|
133
|
+
const method = String(fetchInit.method || "GET").toUpperCase();
|
|
134
|
+
const headers = mergeHeaders(await resolveHeaders(defaultHeaders), new Headers(initHeaders || undefined));
|
|
135
|
+
|
|
136
|
+
let body = fetchInit.body;
|
|
137
|
+
if (json !== undefined) {
|
|
138
|
+
body = JSON.stringify(json);
|
|
139
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const ctx = { url: joinUrl(baseUrl, input, query), init: { ...fetchInit, method, headers, body } };
|
|
143
|
+
if (onRequest) await onRequest(ctx);
|
|
144
|
+
const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
|
|
145
|
+
const finalMethod = String(ctx.init.method || method).toUpperCase();
|
|
146
|
+
|
|
147
|
+
// --- retry policy ------------------------------------------------------
|
|
148
|
+
const retryCfg = retryOpt === false || defaultRetry === false ? false : { ...defaultRetry, ...retryOpt };
|
|
149
|
+
const methods = (retryCfg && retryCfg.methods) || DEFAULT_RETRY_METHODS;
|
|
150
|
+
const hasIdempotencyKey = finalHeaders.has("idempotency-key");
|
|
151
|
+
const canRetry =
|
|
152
|
+
retryCfg !== false &&
|
|
153
|
+
!isStreamBody(ctx.init.body) && // a consumed stream can't be replayed
|
|
154
|
+
(methods.map((m) => m.toUpperCase()).includes(finalMethod) || hasIdempotencyKey);
|
|
155
|
+
|
|
156
|
+
const attemptOnce = () =>
|
|
157
|
+
withTimeout(
|
|
158
|
+
(attemptSignal) => {
|
|
159
|
+
const attemptInit = { ...ctx.init, method: finalMethod, headers: finalHeaders, signal: attemptSignal };
|
|
160
|
+
const useDedupe = dedupe && !signal && !ctx.init.body && (finalMethod === "GET" || finalMethod === "HEAD");
|
|
161
|
+
if (!useDedupe) return doFetch(ctx.url, attemptInit);
|
|
162
|
+
// No per-attempt signal on a shared request: aborting one caller
|
|
163
|
+
// must never cancel everyone else's.
|
|
164
|
+
// It still gets its own timeout so a hung server can't leak the request.
|
|
165
|
+
const sharedInit = { ...attemptInit, signal: timeout === false ? undefined : AbortSignal.timeout(timeout) };
|
|
166
|
+
const key = `${finalMethod} ${ctx.url} ${JSON.stringify([...finalHeaders.entries()].sort())}`;
|
|
167
|
+
return sharedFetch(key, ctx.url, sharedInit).then((res) => res.clone());
|
|
168
|
+
},
|
|
169
|
+
timeout === false ? Infinity : timeout,
|
|
170
|
+
{ signal }
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const { statusCodes, methods: _methods, retries = 2, shouldRetry: customShouldRetry, ...retryRest } = retryCfg || {};
|
|
174
|
+
|
|
175
|
+
let response;
|
|
176
|
+
try {
|
|
177
|
+
response = canRetry
|
|
178
|
+
? await withRetry(attemptOnce, {
|
|
179
|
+
...retryRest,
|
|
180
|
+
retries,
|
|
181
|
+
retryStatusCodes: statusCodes || DEFAULT_RETRY_STATUS_CODES,
|
|
182
|
+
shouldRetry:
|
|
183
|
+
customShouldRetry ||
|
|
184
|
+
((err) => {
|
|
185
|
+
const n = normalizeError(err);
|
|
186
|
+
return n.isNetworkError || n.isTimeout;
|
|
187
|
+
}),
|
|
188
|
+
signal,
|
|
189
|
+
})
|
|
190
|
+
: await attemptOnce();
|
|
191
|
+
} catch (err) {
|
|
192
|
+
// Present a user abort as the plain AbortError they caused, not as retry internals.
|
|
193
|
+
if (err instanceof RetryAbortedError && signal && signal.aborted) throw signal.reason ?? err;
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (onResponse) await onResponse({ url: ctx.url, init: ctx.init, response });
|
|
198
|
+
if (throwHttpErrors && !response.ok) {
|
|
199
|
+
throw new HTTPError(response, { method: finalMethod, url: ctx.url });
|
|
200
|
+
}
|
|
201
|
+
return response;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Like `request`, but resolves to the parsed JSON body (null for an empty body). */
|
|
205
|
+
request.json = async (input, init) => {
|
|
206
|
+
const response = await request(input, init);
|
|
207
|
+
const { data, error } = await parseJsonSafe(response);
|
|
208
|
+
if (error) throw error;
|
|
209
|
+
return data;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** A new fetch with these defaults merged over the current ones (headers and retry are merged, the rest replaced). */
|
|
213
|
+
request.extend = (overrides = {}) => createFetch(mergeDefaults(defaults, overrides));
|
|
214
|
+
|
|
215
|
+
return request;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { createFetch, HTTPError };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a concurrency limiter: at most `concurrency` calls run at once,
|
|
3
|
+
* the rest wait in a FIFO queue. Handy for "download these 200 files, 4 at a
|
|
4
|
+
* time" without pulling in a queue library. (Same idea as p-limit.)
|
|
5
|
+
*
|
|
6
|
+
* ```js
|
|
7
|
+
* const limit = createLimiter(4);
|
|
8
|
+
* const bytes = await Promise.all(urls.map((u) => limit(() => download(u))));
|
|
9
|
+
* // or: await limit.map(urls, (u) => download(u));
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* @param {number} [concurrency=Infinity] - integer >= 1, or Infinity
|
|
13
|
+
*/
|
|
14
|
+
function createLimiter(concurrency = Infinity) {
|
|
15
|
+
if (concurrency !== Infinity && !(Number.isInteger(concurrency) && concurrency >= 1)) {
|
|
16
|
+
throw new TypeError("createLimiter: `concurrency` must be an integer >= 1 (or Infinity)");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const queue = [];
|
|
20
|
+
let active = 0;
|
|
21
|
+
|
|
22
|
+
function next() {
|
|
23
|
+
while (active < concurrency && queue.length > 0) {
|
|
24
|
+
active++;
|
|
25
|
+
queue.shift()();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function limit(fn, ...args) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
queue.push(() => {
|
|
32
|
+
Promise.resolve()
|
|
33
|
+
.then(() => fn(...args))
|
|
34
|
+
.then(resolve, reject)
|
|
35
|
+
.finally(() => {
|
|
36
|
+
active--;
|
|
37
|
+
next();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
next();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Object.defineProperties(limit, {
|
|
45
|
+
activeCount: { get: () => active },
|
|
46
|
+
pendingCount: { get: () => queue.length },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** Runs `fn(item, index)` for every item through the limiter. Results keep input order. */
|
|
50
|
+
limit.map = (items, fn) => Promise.all(Array.from(items, (item, i) => limit(fn, item, i)));
|
|
51
|
+
|
|
52
|
+
return limit;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { createLimiter };
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { withRetry, RetryAbortedError } = require("./withRetry");
|
|
1
|
+
const { withRetry, RetryAbortedError, StopRetry } = require("./withRetry");
|
|
2
2
|
const { withTimeout, TimeoutError } = require("./withTimeout");
|
|
3
3
|
const { normalizeError } = require("./normalizeError");
|
|
4
4
|
const { parseJsonSafe } = require("./parseJsonSafe");
|
|
@@ -6,10 +6,13 @@ const { buildQuery } = require("./buildQuery");
|
|
|
6
6
|
const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./retryAfterAware");
|
|
7
7
|
const { withProgress, BodyTooLargeError } = require("./withProgress");
|
|
8
8
|
const { withDedupe } = require("./withDedupe");
|
|
9
|
+
const { createLimiter } = require("./createLimiter");
|
|
10
|
+
const { createFetch, HTTPError } = require("./createFetch");
|
|
9
11
|
|
|
10
12
|
module.exports = {
|
|
11
13
|
withRetry,
|
|
12
14
|
RetryAbortedError,
|
|
15
|
+
StopRetry,
|
|
13
16
|
withTimeout,
|
|
14
17
|
TimeoutError,
|
|
15
18
|
normalizeError,
|
|
@@ -21,4 +24,7 @@ module.exports = {
|
|
|
21
24
|
withProgress,
|
|
22
25
|
BodyTooLargeError,
|
|
23
26
|
withDedupe,
|
|
27
|
+
createLimiter,
|
|
28
|
+
createFetch,
|
|
29
|
+
HTTPError,
|
|
24
30
|
};
|
package/src/index.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import reqkit from "./index.js";
|
|
|
3
3
|
export const {
|
|
4
4
|
withRetry,
|
|
5
5
|
RetryAbortedError,
|
|
6
|
+
StopRetry,
|
|
6
7
|
withTimeout,
|
|
7
8
|
TimeoutError,
|
|
8
9
|
normalizeError,
|
|
@@ -14,6 +15,9 @@ export const {
|
|
|
14
15
|
withProgress,
|
|
15
16
|
BodyTooLargeError,
|
|
16
17
|
withDedupe,
|
|
18
|
+
createLimiter,
|
|
19
|
+
createFetch,
|
|
20
|
+
HTTPError,
|
|
17
21
|
} = reqkit;
|
|
18
22
|
|
|
19
23
|
export default reqkit;
|
package/src/retryAfterAware.js
CHANGED
|
@@ -1,75 +1,23 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { withRetry } = require("./withRetry");
|
|
2
2
|
const { normalizeError } = require("./normalizeError");
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Parses a Retry-After header value into milliseconds. Per HTTP spec it's
|
|
6
|
-
* either an integer number of seconds, or an HTTP-date.
|
|
7
|
-
*/
|
|
8
|
-
function parseRetryAfter(headerValue) {
|
|
9
|
-
if (headerValue === null || headerValue === undefined) return null;
|
|
10
|
-
const value = String(headerValue).trim();
|
|
11
|
-
if (!value) return null;
|
|
12
|
-
|
|
13
|
-
// delta-seconds (spec says integer; tolerate "1.5" from sloppy servers)
|
|
14
|
-
if (/^\d+(\.\d+)?$/.test(value)) {
|
|
15
|
-
return Number(value) * 1000;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const dateMs = Date.parse(value);
|
|
19
|
-
if (!Number.isNaN(dateMs)) {
|
|
20
|
-
return Math.max(0, dateMs - Date.now());
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Reads a wait time from common rate-limit headers, trying Retry-After first,
|
|
28
|
-
* then falling back to X-RateLimit-Reset (epoch seconds or ms) if present.
|
|
29
|
-
*/
|
|
30
|
-
function getWaitMsFromHeaders(headers) {
|
|
31
|
-
if (!headers || typeof headers.get !== "function") return null;
|
|
32
|
-
|
|
33
|
-
const retryAfter = parseRetryAfter(headers.get("retry-after"));
|
|
34
|
-
if (retryAfter !== null) return retryAfter;
|
|
35
|
-
|
|
36
|
-
const resetHeader = headers.get("x-ratelimit-reset") || headers.get("x-rate-limit-reset");
|
|
37
|
-
if (resetHeader) {
|
|
38
|
-
const resetValue = Number(resetHeader);
|
|
39
|
-
if (!Number.isNaN(resetValue)) {
|
|
40
|
-
// Could be epoch seconds or epoch ms — treat values below a
|
|
41
|
-
// year-2100-in-seconds threshold as seconds.
|
|
42
|
-
const resetMs = resetValue > 4102444800 ? resetValue : resetValue * 1000;
|
|
43
|
-
return Math.max(0, resetMs - Date.now());
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
3
|
+
const { parseRetryAfter, getWaitMsFromHeaders } = require("./_http");
|
|
49
4
|
|
|
50
5
|
function defaultShouldRetryError(err) {
|
|
51
6
|
const normalized = normalizeError(err);
|
|
52
7
|
return normalized.isNetworkError || normalized.isTimeout;
|
|
53
8
|
}
|
|
54
9
|
|
|
55
|
-
async function discardBody(response) {
|
|
56
|
-
// An unread body keeps the underlying connection busy; cancel it before
|
|
57
|
-
// we sit through a backoff and fire the next request.
|
|
58
|
-
try {
|
|
59
|
-
if (response.body && typeof response.body.cancel === "function" && !response.bodyUsed) {
|
|
60
|
-
await response.body.cancel();
|
|
61
|
-
}
|
|
62
|
-
} catch {
|
|
63
|
-
// locked / already consumed / not a web stream — safe to ignore
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
10
|
/**
|
|
68
11
|
* Retries a fetch-like call, honoring Retry-After / X-RateLimit-Reset
|
|
69
12
|
* headers on 429/503 responses instead of blind exponential backoff — most
|
|
70
13
|
* generic retry libraries treat rate-limit responses like any other failure
|
|
71
14
|
* and ignore the header the server is explicitly giving you.
|
|
72
15
|
*
|
|
16
|
+
* This is a thin preset over `withRetry` (retryStatusCodes + Retry-After,
|
|
17
|
+
* no jitter, network errors/timeouts only for thrown errors). Reach for
|
|
18
|
+
* `withRetry` directly when you need jitter, `maxRetryTime`, `StopRetry`,
|
|
19
|
+
* `calculateDelay`, ...
|
|
20
|
+
*
|
|
73
21
|
* Also retries thrown network errors / timeouts (see `shouldRetryError`),
|
|
74
22
|
* and the whole loop — including any backoff wait — can be cancelled with
|
|
75
23
|
* `signal`. The body of each response that gets retried is cancelled so the
|
|
@@ -92,7 +40,7 @@ async function discardBody(response) {
|
|
|
92
40
|
* @param {AbortSignal} [opts.signal] - cancels the retry loop, throwing
|
|
93
41
|
* RetryAbortedError
|
|
94
42
|
*/
|
|
95
|
-
|
|
43
|
+
function retryAfterAware(fn, opts = {}) {
|
|
96
44
|
const {
|
|
97
45
|
retries = 3,
|
|
98
46
|
retryStatusCodes = [429, 503],
|
|
@@ -104,34 +52,24 @@ async function retryAfterAware(fn, opts = {}) {
|
|
|
104
52
|
signal,
|
|
105
53
|
} = opts;
|
|
106
54
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (onRetryError)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return response;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const headerWaitMs = getWaitMsFromHeaders(response.headers);
|
|
129
|
-
const waitMs = Math.min(headerWaitMs ?? fallbackDelayMs * 2 ** attempt, maxDelayMs);
|
|
130
|
-
|
|
131
|
-
await discardBody(response);
|
|
132
|
-
if (onRetry) onRetry(response.status, waitMs, attempt);
|
|
133
|
-
await sleep(waitMs, signal);
|
|
134
|
-
}
|
|
55
|
+
return withRetry(fn, {
|
|
56
|
+
retries,
|
|
57
|
+
retryStatusCodes,
|
|
58
|
+
baseDelayMs: fallbackDelayMs,
|
|
59
|
+
maxDelayMs,
|
|
60
|
+
maxRetryAfterMs: maxDelayMs,
|
|
61
|
+
factor: 2,
|
|
62
|
+
jitter: 0,
|
|
63
|
+
shouldRetry: shouldRetryError,
|
|
64
|
+
onRetry: (err, attempt, waitMs, ctx) => {
|
|
65
|
+
if (ctx.reason === "result") {
|
|
66
|
+
if (onRetry) onRetry(ctx.status, waitMs, attempt);
|
|
67
|
+
} else if (onRetryError) {
|
|
68
|
+
onRetryError(err, waitMs, attempt);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
signal,
|
|
72
|
+
});
|
|
135
73
|
}
|
|
136
74
|
|
|
137
75
|
module.exports = { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders };
|
package/src/withRetry.js
CHANGED
|
@@ -1,58 +1,147 @@
|
|
|
1
1
|
const { RetryAbortedError, sleep } = require("./_sleep");
|
|
2
|
+
const { getWaitMsFromHeaders, discardBody } = require("./_http");
|
|
2
3
|
|
|
3
4
|
function defaultShouldRetry(err) {
|
|
4
|
-
// Retry
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// Retry thrown errors except deliberate aborts. Narrow it with `shouldRetry`
|
|
6
|
+
// (e.g. network errors only). HTTP *statuses* aren't errors with fetch, so
|
|
7
|
+
// opt in with `retryStatusCodes` / `shouldRetryResult`.
|
|
7
8
|
return Boolean(err) && err.name !== "AbortError";
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
*
|
|
12
|
+
* Throw this from inside `fn` to stop retrying immediately. If you pass an
|
|
13
|
+
* Error, that exact error is what withRetry rethrows; with a string, the
|
|
14
|
+
* StopRetry itself is thrown. (Same idea as p-retry's AbortError.)
|
|
15
|
+
*/
|
|
16
|
+
class StopRetry extends Error {
|
|
17
|
+
constructor(reasonOrMessage) {
|
|
18
|
+
const isError = reasonOrMessage instanceof Error;
|
|
19
|
+
super(isError ? reasonOrMessage.message : String(reasonOrMessage ?? "Retrying was stopped"));
|
|
20
|
+
this.name = "StopRetry";
|
|
21
|
+
this.original = isError ? reasonOrMessage : null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Retries an async function with exponential backoff + jitter. Retries on
|
|
27
|
+
* thrown errors, and — opt-in — on *results* too (HTTP status codes, or any
|
|
28
|
+
* predicate), honoring Retry-After / X-RateLimit-Reset when the result has
|
|
29
|
+
* headers.
|
|
12
30
|
*
|
|
13
31
|
* @param {(attempt: number) => Promise<any>} fn
|
|
14
32
|
* @param {object} [opts]
|
|
15
|
-
* @param {number} [opts.retries=3] - max retry attempts after the first try
|
|
33
|
+
* @param {number} [opts.retries=3] - max retry attempts after the first try (Infinity is fine with maxRetryTime)
|
|
16
34
|
* @param {number} [opts.baseDelayMs=300] - initial delay
|
|
17
|
-
* @param {number} [opts.maxDelayMs=10000] -
|
|
18
|
-
* @param {
|
|
19
|
-
* @param {
|
|
35
|
+
* @param {number} [opts.maxDelayMs=10000] - backoff ceiling
|
|
36
|
+
* @param {number} [opts.factor=2] - backoff multiplier
|
|
37
|
+
* @param {number|"full"} [opts.jitter=0.2] - fraction of extra random delay
|
|
38
|
+
* added on top (0 disables); "full" picks uniformly in [0, delay] — the
|
|
39
|
+
* AWS "full jitter" strategy, best when many clients retry at once
|
|
40
|
+
* @param {number} [opts.maxRetryTime=Infinity] - total time budget in ms
|
|
41
|
+
* across all attempts and waits; once the next wait would blow it, stop
|
|
42
|
+
* and surface the last error/result
|
|
43
|
+
* @param {(err: any, attempt: number) => boolean} [opts.shouldRetry] - for thrown errors
|
|
44
|
+
* @param {number[]} [opts.retryStatusCodes=[]] - retry when the result's `.status` is in this list
|
|
45
|
+
* @param {(result: any, attempt: number) => boolean} [opts.shouldRetryResult] - retry on any result predicate
|
|
46
|
+
* @param {boolean} [opts.respectRetryAfter=true] - use Retry-After/X-RateLimit-Reset from `result.headers`
|
|
47
|
+
* @param {number} [opts.maxRetryAfterMs=60000] - cap on a header-provided wait
|
|
48
|
+
* @param {(info: {attempt: number, error: any, result: any, delayMs: number}) => number|false} [opts.calculateDelay]
|
|
49
|
+
* - override the wait; return a number of ms, or false to stop retrying
|
|
50
|
+
* @param {(err: any, attempt: number, delayMs: number, ctx: {reason: "error"|"result", result?: any, status?: number}) => void} [opts.onRetry]
|
|
51
|
+
* - `err` is undefined when the retry was triggered by a result
|
|
20
52
|
* @param {AbortSignal} [opts.signal] - cancels the whole retry loop,
|
|
21
|
-
* including any pending backoff wait, throwing RetryAbortedError
|
|
22
|
-
*
|
|
23
|
-
* this one governs whether *another attempt starts at all*.
|
|
53
|
+
* including any pending backoff wait, throwing RetryAbortedError (with the
|
|
54
|
+
* last failure as `cause`). Separate from a per-call signal you pass into
|
|
55
|
+
* `fn` yourself — this one governs whether *another attempt starts at all*.
|
|
24
56
|
*/
|
|
25
57
|
async function withRetry(fn, opts = {}) {
|
|
26
58
|
const {
|
|
27
59
|
retries = 3,
|
|
28
60
|
baseDelayMs = 300,
|
|
29
61
|
maxDelayMs = 10000,
|
|
62
|
+
factor = 2,
|
|
63
|
+
jitter = 0.2,
|
|
64
|
+
maxRetryTime = Infinity,
|
|
30
65
|
shouldRetry = defaultShouldRetry,
|
|
66
|
+
retryStatusCodes = [],
|
|
67
|
+
shouldRetryResult,
|
|
68
|
+
respectRetryAfter = true,
|
|
69
|
+
maxRetryAfterMs = 60000,
|
|
70
|
+
calculateDelay,
|
|
31
71
|
onRetry,
|
|
32
72
|
signal,
|
|
33
73
|
} = opts;
|
|
34
74
|
|
|
75
|
+
const startedAt = Date.now();
|
|
76
|
+
|
|
77
|
+
function backoff(attempt) {
|
|
78
|
+
const exponential = Math.min(baseDelayMs * factor ** attempt, maxDelayMs);
|
|
79
|
+
if (jitter === "full") return Math.round(Math.random() * exponential);
|
|
80
|
+
const extra = Math.random() * exponential * (Number(jitter) || 0);
|
|
81
|
+
return Math.round(exponential + extra);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Returns the wait in ms, or null if retrying should stop (custom
|
|
85
|
+
// calculateDelay said so, or the time budget would be exceeded).
|
|
86
|
+
function planWait(defaultMs, attempt, error, result) {
|
|
87
|
+
let waitMs = defaultMs;
|
|
88
|
+
if (calculateDelay) {
|
|
89
|
+
const custom = calculateDelay({ attempt, error, result, delayMs: defaultMs });
|
|
90
|
+
if (custom === false || custom === null || custom === undefined) return null;
|
|
91
|
+
waitMs = Math.max(0, Number(custom) || 0);
|
|
92
|
+
}
|
|
93
|
+
if (Date.now() - startedAt + waitMs > maxRetryTime) return null;
|
|
94
|
+
return waitMs;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function wantsResultRetry(result, attempt) {
|
|
98
|
+
if (
|
|
99
|
+
retryStatusCodes.length > 0 &&
|
|
100
|
+
result &&
|
|
101
|
+
typeof result.status === "number" &&
|
|
102
|
+
retryStatusCodes.includes(result.status)
|
|
103
|
+
) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return shouldRetryResult ? Boolean(shouldRetryResult(result, attempt)) : false;
|
|
107
|
+
}
|
|
108
|
+
|
|
35
109
|
let lastErr;
|
|
36
|
-
for (let attempt = 0;
|
|
110
|
+
for (let attempt = 0; ; attempt++) {
|
|
37
111
|
if (signal?.aborted) {
|
|
38
112
|
throw new RetryAbortedError(lastErr);
|
|
39
113
|
}
|
|
114
|
+
|
|
115
|
+
let result;
|
|
40
116
|
try {
|
|
41
|
-
|
|
117
|
+
result = await fn(attempt);
|
|
42
118
|
} catch (err) {
|
|
119
|
+
if (err instanceof StopRetry) throw err.original ?? err;
|
|
43
120
|
lastErr = err;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const waitMs = Math.round(exponential + jitter);
|
|
51
|
-
if (onRetry) onRetry(err, attempt, waitMs);
|
|
121
|
+
if (attempt >= retries || !shouldRetry(err, attempt)) throw err;
|
|
122
|
+
|
|
123
|
+
const waitMs = planWait(backoff(attempt), attempt, err, undefined);
|
|
124
|
+
if (waitMs === null) throw err;
|
|
125
|
+
|
|
126
|
+
if (onRetry) onRetry(err, attempt, waitMs, { reason: "error" });
|
|
52
127
|
await sleep(waitMs, signal, err);
|
|
128
|
+
continue;
|
|
53
129
|
}
|
|
130
|
+
|
|
131
|
+
if (attempt >= retries || !wantsResultRetry(result, attempt)) return result;
|
|
132
|
+
|
|
133
|
+
let defaultMs = backoff(attempt);
|
|
134
|
+
if (respectRetryAfter) {
|
|
135
|
+
const headerMs = getWaitMsFromHeaders(result && result.headers);
|
|
136
|
+
if (headerMs !== null) defaultMs = Math.min(headerMs, maxRetryAfterMs);
|
|
137
|
+
}
|
|
138
|
+
const waitMs = planWait(defaultMs, attempt, undefined, result);
|
|
139
|
+
if (waitMs === null) return result;
|
|
140
|
+
|
|
141
|
+
await discardBody(result);
|
|
142
|
+
if (onRetry) onRetry(undefined, attempt, waitMs, { reason: "result", result, status: result && result.status });
|
|
143
|
+
await sleep(waitMs, signal);
|
|
54
144
|
}
|
|
55
|
-
throw lastErr;
|
|
56
145
|
}
|
|
57
146
|
|
|
58
|
-
module.exports = { withRetry, RetryAbortedError };
|
|
147
|
+
module.exports = { withRetry, RetryAbortedError, StopRetry };
|
package/src/withTimeout.js
CHANGED
|
@@ -7,34 +7,62 @@ class TimeoutError extends Error {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Races an async function against a timeout.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Races an async function against a timeout. `fn` is handed an AbortSignal
|
|
11
|
+
* so the underlying request can actually be cancelled (not just abandoned) —
|
|
12
|
+
* e.g. `withTimeout(signal => fetch(url, { signal }), 5000)`.
|
|
13
|
+
*
|
|
14
|
+
* Pass your own `signal` to also cancel from the outside (user navigated
|
|
15
|
+
* away, shutdown...). That signal is linked into the one `fn` receives, and
|
|
16
|
+
* aborting it rejects with its reason even if `fn` ignores the signal.
|
|
17
|
+
* `ms: Infinity` disables the timer (handy to only link a signal).
|
|
13
18
|
*
|
|
14
19
|
* @param {(signal: AbortSignal) => Promise<any>} fn
|
|
15
20
|
* @param {number} ms
|
|
21
|
+
* @param {object} [opts]
|
|
22
|
+
* @param {AbortSignal} [opts.signal] - external cancellation
|
|
16
23
|
*/
|
|
17
|
-
async function withTimeout(fn, ms) {
|
|
24
|
+
async function withTimeout(fn, ms, opts = {}) {
|
|
25
|
+
const { signal } = opts;
|
|
26
|
+
|
|
27
|
+
if (typeof ms !== "number" || Number.isNaN(ms) || ms < 0) {
|
|
28
|
+
throw new TypeError("withTimeout: `ms` must be a non-negative number (or Infinity)");
|
|
29
|
+
}
|
|
30
|
+
signal?.throwIfAborted?.();
|
|
31
|
+
|
|
18
32
|
const controller = new AbortController();
|
|
19
|
-
|
|
33
|
+
let timedOut = false;
|
|
34
|
+
|
|
35
|
+
const timer =
|
|
36
|
+
ms === Infinity
|
|
37
|
+
? null
|
|
38
|
+
: setTimeout(() => {
|
|
39
|
+
timedOut = true;
|
|
40
|
+
controller.abort(new TimeoutError(ms));
|
|
41
|
+
}, ms);
|
|
42
|
+
|
|
43
|
+
const onExternalAbort = () => controller.abort(signal.reason);
|
|
44
|
+
signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
20
45
|
|
|
21
|
-
const
|
|
22
|
-
controller.signal.addEventListener(
|
|
46
|
+
const guard = new Promise((_, reject) => {
|
|
47
|
+
controller.signal.addEventListener(
|
|
48
|
+
"abort",
|
|
49
|
+
() => reject(timedOut ? new TimeoutError(ms) : controller.signal.reason),
|
|
50
|
+
{ once: true }
|
|
51
|
+
);
|
|
23
52
|
});
|
|
24
53
|
|
|
25
54
|
try {
|
|
26
|
-
return await Promise.race([fn(controller.signal),
|
|
55
|
+
return await Promise.race([fn(controller.signal), guard]);
|
|
27
56
|
} catch (err) {
|
|
28
57
|
// If our timeout is what triggered the abort, always surface TimeoutError —
|
|
29
|
-
// regardless of whether fn's own rejection or our
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
if (
|
|
33
|
-
throw new TimeoutError(ms);
|
|
34
|
-
}
|
|
58
|
+
// regardless of whether fn's own rejection or our guard "won" the race
|
|
59
|
+
// (that's a listener-registration-order detail callers shouldn't have to
|
|
60
|
+
// think about).
|
|
61
|
+
if (timedOut) throw new TimeoutError(ms);
|
|
35
62
|
throw err;
|
|
36
63
|
} finally {
|
|
37
|
-
clearTimeout(timer);
|
|
64
|
+
if (timer) clearTimeout(timer);
|
|
65
|
+
signal?.removeEventListener("abort", onExternalAbort);
|
|
38
66
|
}
|
|
39
67
|
}
|
|
40
68
|
|
package/types/index.d.ts
CHANGED
|
@@ -1,12 +1,56 @@
|
|
|
1
|
+
export interface RetryContext {
|
|
2
|
+
reason: "error" | "result";
|
|
3
|
+
/** The result that triggered the retry (reason "result"). */
|
|
4
|
+
result?: unknown;
|
|
5
|
+
/** The result's HTTP status (reason "result"). */
|
|
6
|
+
status?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CalculateDelayInfo {
|
|
10
|
+
attempt: number;
|
|
11
|
+
error: unknown;
|
|
12
|
+
result: unknown;
|
|
13
|
+
/** The delay reqkit would have used. */
|
|
14
|
+
delayMs: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
1
17
|
export interface RetryOptions {
|
|
18
|
+
/** Max retries after the first try. Infinity is fine together with maxRetryTime. Default 3. */
|
|
2
19
|
retries?: number;
|
|
20
|
+
/** Default 300. */
|
|
3
21
|
baseDelayMs?: number;
|
|
22
|
+
/** Backoff ceiling. Default 10000. */
|
|
4
23
|
maxDelayMs?: number;
|
|
24
|
+
/** Backoff multiplier. Default 2. */
|
|
25
|
+
factor?: number;
|
|
26
|
+
/** Extra random fraction (default 0.2), or "full" for uniform [0, delay]. */
|
|
27
|
+
jitter?: number | "full";
|
|
28
|
+
/** Total time budget across attempts and waits, in ms. */
|
|
29
|
+
maxRetryTime?: number;
|
|
30
|
+
/** For thrown errors. */
|
|
5
31
|
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
|
6
|
-
|
|
32
|
+
/** Retry when the result's `.status` is in this list. */
|
|
33
|
+
retryStatusCodes?: number[];
|
|
34
|
+
/** Retry on any result predicate. */
|
|
35
|
+
shouldRetryResult?: (result: any, attempt: number) => boolean;
|
|
36
|
+
/** Use Retry-After / X-RateLimit-Reset from `result.headers`. Default true. */
|
|
37
|
+
respectRetryAfter?: boolean;
|
|
38
|
+
/** Cap on a header-provided wait. Default 60000. */
|
|
39
|
+
maxRetryAfterMs?: number;
|
|
40
|
+
/** Return ms to wait, or false to stop retrying. */
|
|
41
|
+
calculateDelay?: (info: CalculateDelayInfo) => number | false | null | undefined;
|
|
42
|
+
/** `err` is undefined when a result (not an error) triggered the retry. */
|
|
43
|
+
onRetry?: (err: unknown, attempt: number, delayMs: number, ctx: RetryContext) => void;
|
|
7
44
|
signal?: AbortSignal;
|
|
8
45
|
}
|
|
9
46
|
|
|
47
|
+
/** Throw inside `fn` to stop retrying. An Error argument is rethrown as-is. */
|
|
48
|
+
export class StopRetry extends Error {
|
|
49
|
+
name: "StopRetry";
|
|
50
|
+
original: Error | null;
|
|
51
|
+
constructor(reasonOrMessage?: unknown);
|
|
52
|
+
}
|
|
53
|
+
|
|
10
54
|
export class RetryAbortedError extends Error {
|
|
11
55
|
name: "RetryAbortedError";
|
|
12
56
|
/** The last failure before the loop was aborted, if there was one. */
|
|
@@ -20,7 +64,17 @@ export class TimeoutError extends Error {
|
|
|
20
64
|
constructor(ms: number);
|
|
21
65
|
}
|
|
22
66
|
|
|
23
|
-
export
|
|
67
|
+
export interface TimeoutOptions {
|
|
68
|
+
/** External cancellation, linked into the signal `fn` receives. */
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `ms` may be Infinity to disable the timer (only link `signal`). */
|
|
73
|
+
export function withTimeout<T>(
|
|
74
|
+
fn: (signal: AbortSignal) => Promise<T>,
|
|
75
|
+
ms: number,
|
|
76
|
+
opts?: TimeoutOptions
|
|
77
|
+
): Promise<T>;
|
|
24
78
|
|
|
25
79
|
export interface NormalizedError {
|
|
26
80
|
message: string;
|
|
@@ -99,3 +153,81 @@ export function withDedupe<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
99
153
|
fn: Fn,
|
|
100
154
|
opts?: DedupeOptions
|
|
101
155
|
): Fn;
|
|
156
|
+
|
|
157
|
+
export interface Limiter {
|
|
158
|
+
<A extends any[], R>(fn: (...args: A) => R | Promise<R>, ...args: A): Promise<R>;
|
|
159
|
+
readonly activeCount: number;
|
|
160
|
+
readonly pendingCount: number;
|
|
161
|
+
/** Runs fn(item, index) for each item through the limiter; keeps input order. */
|
|
162
|
+
map<T, R>(items: Iterable<T>, fn: (item: T, index: number) => R | Promise<R>): Promise<R[]>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function createLimiter(concurrency?: number): Limiter;
|
|
166
|
+
|
|
167
|
+
export class HTTPError extends Error {
|
|
168
|
+
name: "HTTPError";
|
|
169
|
+
status: number;
|
|
170
|
+
statusText: string;
|
|
171
|
+
response: Response;
|
|
172
|
+
url: string;
|
|
173
|
+
method: string;
|
|
174
|
+
constructor(response: Response, request: { method: string; url: string });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface CreateFetchRetryOptions {
|
|
178
|
+
/** Default 2. */
|
|
179
|
+
retries?: number;
|
|
180
|
+
/** Default [408, 425, 429, 500, 502, 503, 504]. */
|
|
181
|
+
statusCodes?: number[];
|
|
182
|
+
/** Methods that may be retried. Default GET, HEAD, OPTIONS, PUT, DELETE. */
|
|
183
|
+
methods?: string[];
|
|
184
|
+
baseDelayMs?: number;
|
|
185
|
+
maxDelayMs?: number;
|
|
186
|
+
factor?: number;
|
|
187
|
+
jitter?: number | "full";
|
|
188
|
+
maxRetryTime?: number;
|
|
189
|
+
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
|
190
|
+
calculateDelay?: RetryOptions["calculateDelay"];
|
|
191
|
+
onRetry?: RetryOptions["onRetry"];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface FetchHookContext {
|
|
195
|
+
url: string;
|
|
196
|
+
init: RequestInit & { headers: Headers };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface CreateFetchDefaults {
|
|
200
|
+
baseUrl?: string;
|
|
201
|
+
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
202
|
+
/** Per attempt, in ms. false disables. Default 30000. */
|
|
203
|
+
timeout?: number | false;
|
|
204
|
+
/** false disables retrying. */
|
|
205
|
+
retry?: false | CreateFetchRetryOptions;
|
|
206
|
+
/** Merge identical concurrent GET/HEAD calls. Default false. */
|
|
207
|
+
dedupe?: boolean;
|
|
208
|
+
/** Throw HTTPError on non-2xx. Default true. */
|
|
209
|
+
throwHttpErrors?: boolean;
|
|
210
|
+
fetch?: typeof fetch;
|
|
211
|
+
onRequest?: (ctx: FetchHookContext) => void | Promise<void>;
|
|
212
|
+
onResponse?: (ctx: FetchHookContext & { response: Response }) => void | Promise<void>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface ReqkitRequestInit extends Omit<RequestInit, "headers"> {
|
|
216
|
+
headers?: HeadersInit;
|
|
217
|
+
query?: Record<string, unknown>;
|
|
218
|
+
/** Serialized as the body with a JSON content-type. */
|
|
219
|
+
json?: unknown;
|
|
220
|
+
timeout?: number | false;
|
|
221
|
+
retry?: false | CreateFetchRetryOptions;
|
|
222
|
+
throwHttpErrors?: boolean;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface ReqkitFetch {
|
|
226
|
+
(input: string, init?: ReqkitRequestInit): Promise<Response>;
|
|
227
|
+
/** Resolves to the parsed JSON body (null for an empty body). */
|
|
228
|
+
json<T = unknown>(input: string, init?: ReqkitRequestInit): Promise<T>;
|
|
229
|
+
/** New instance with these defaults merged over the current ones. */
|
|
230
|
+
extend(overrides?: CreateFetchDefaults): ReqkitFetch;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function createFetch(defaults?: CreateFetchDefaults): ReqkitFetch;
|