@boy-offi9-inc/reqkit 0.1.7 → 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/LICENSE +1 -1
- package/README.md +181 -22
- package/package.json +32 -8
- package/src/_http.js +59 -0
- package/src/_sleep.js +32 -0
- package/src/buildQuery.js +22 -8
- package/src/createFetch.js +218 -0
- package/src/createLimiter.js +55 -0
- package/src/index.js +9 -2
- package/src/index.mjs +5 -0
- package/src/normalizeError.js +83 -38
- package/src/retryAfterAware.js +45 -60
- package/src/withDedupe.js +43 -5
- package/src/withProgress.js +76 -17
- package/src/withRetry.js +113 -49
- package/src/withTimeout.js +43 -15
- package/types/index.d.mts +3 -0
- package/types/index.d.ts +159 -3
package/LICENSE
CHANGED
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) |
|
|
29
67
|
|
|
30
|
-
|
|
68
|
+
---
|
|
31
69
|
|
|
32
|
-
|
|
70
|
+
## Why this exists
|
|
33
71
|
|
|
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.
|
|
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.
|
|
37
73
|
|
|
38
|
-
|
|
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:
|
|
75
|
+
|
|
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,7 +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
|
+
```
|
|
144
|
+
|
|
145
|
+
### `withTimeout(fn, ms, opts?)`
|
|
80
146
|
|
|
81
147
|
Races an async function against a timeout, passing it an `AbortSignal` so the underlying request can actually be cancelled.
|
|
82
148
|
|
|
@@ -88,14 +154,22 @@ const data = await withTimeout(
|
|
|
88
154
|
// throws TimeoutError on timeout, regardless of whether fn cooperates with the signal
|
|
89
155
|
```
|
|
90
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
|
+
|
|
91
163
|
### `normalizeError(err)`
|
|
92
164
|
|
|
93
165
|
One consistent error shape regardless of source (fetch, axios, node http, generic):
|
|
94
166
|
|
|
95
167
|
```js
|
|
96
|
-
const { message, status, code, isNetworkError, isTimeout } = normalizeError(err);
|
|
168
|
+
const { message, status, code, isNetworkError, isTimeout, isAborted } = normalizeError(err);
|
|
97
169
|
```
|
|
98
170
|
|
|
171
|
+
Understands Node's `TypeError: fetch failed` (real code read from `err.cause`), browser `Failed to fetch`, undici timeouts, and axios. `isTimeout` is only for real timeouts; a deliberate `AbortController.abort()` sets `isAborted` instead.
|
|
172
|
+
|
|
99
173
|
Every field is always present (`null`/`false` when not applicable) — no existence checks needed.
|
|
100
174
|
|
|
101
175
|
### `parseJsonSafe(input)`
|
|
@@ -116,11 +190,13 @@ buildQuery({ q: "hello world", tag: ["a", "b"] });
|
|
|
116
190
|
// "q=hello%20world&tag=a&tag=b"
|
|
117
191
|
```
|
|
118
192
|
|
|
193
|
+
`Date` values are serialized as ISO 8601. Nested objects throw a `TypeError` (flatten or `JSON.stringify` them first) instead of silently becoming `[object Object]`.
|
|
194
|
+
|
|
119
195
|
For full parsing/nested-object support, use [`query-string`](https://www.npmjs.com/package/query-string) instead — this only covers the common flat-object case with zero dependencies.
|
|
120
196
|
|
|
121
197
|
### `retryAfterAware(fn, opts?)`
|
|
122
198
|
|
|
123
|
-
|
|
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.
|
|
124
200
|
|
|
125
201
|
```js
|
|
126
202
|
const response = await retryAfterAware(
|
|
@@ -136,8 +212,13 @@ const response = await retryAfterAware(
|
|
|
136
212
|
| `fallbackDelayMs` | `1000` | used when no usable header is present |
|
|
137
213
|
| `maxDelayMs` | `60000` | |
|
|
138
214
|
| `onRetry` | — | `(status, waitMs, attempt) => void` |
|
|
215
|
+
| `shouldRetryError` | network errors + timeouts | `(err, attempt) => boolean` — decides whether a *thrown* error is retried |
|
|
216
|
+
| `onRetryError` | — | `(err, waitMs, attempt) => void` |
|
|
217
|
+
| `signal` | — | `AbortSignal` — cancels the loop (even mid-wait), throwing `RetryAbortedError` |
|
|
139
218
|
|
|
140
|
-
|
|
219
|
+
The body of every response that gets retried is cancelled so the connection is freed.
|
|
220
|
+
|
|
221
|
+
### `withProgress(response, onProgress?, opts?)`
|
|
141
222
|
|
|
142
223
|
Reads a streamed `Response` body while reporting progress, returns the collected bytes as a `Uint8Array`.
|
|
143
224
|
|
|
@@ -147,7 +228,14 @@ const bytes = await withProgress(response, ({ loaded, total, percent }) => {
|
|
|
147
228
|
});
|
|
148
229
|
```
|
|
149
230
|
|
|
150
|
-
Works without `Content-Length` too — `total`/`percent` are just `null` in that case, `loaded` is still accurate. Falls back to a single-shot read (still calling `onProgress` once) if the response body is not a stream.
|
|
231
|
+
Works without `Content-Length` too — `total`/`percent` are just `null` in that case, `loaded` is still accurate. They're also `null` when a `Content-Encoding` is applied (the header is the compressed size, but you receive decompressed bytes), and `percent` never exceeds 100. Falls back to a single-shot read (still calling `onProgress` once) if the response body is not a stream.
|
|
232
|
+
|
|
233
|
+
```js
|
|
234
|
+
const bytes = await withProgress(response, onProgress, {
|
|
235
|
+
maxBytes: 50 * 1024 * 1024, // throws BodyTooLargeError (code "EBODYTOOLARGE") past this
|
|
236
|
+
signal: controller.signal, // abort the read
|
|
237
|
+
});
|
|
238
|
+
```
|
|
151
239
|
|
|
152
240
|
### `withDedupe(fn, opts?)`
|
|
153
241
|
|
|
@@ -164,6 +252,8 @@ const [a, b] = await Promise.all([getUser(1), getUser(1)]);
|
|
|
164
252
|
| --- | --- | --- |
|
|
165
253
|
| `keyFn` | `(...args) => JSON.stringify(args)` | derives the dedupe key from call args |
|
|
166
254
|
|
|
255
|
+
Plain arguments are compared by value (object key order doesn't matter); anything else — `AbortSignal`s, `Response`s, class instances, functions — is compared by identity, so calls with different `AbortSignal`s are never merged. Note that merged callers share one promise, so aborting the request affects everyone waiting on it.
|
|
256
|
+
|
|
167
257
|
This is deduplication of *concurrent* calls, not a cache — once a call settles (success or failure), the next call with the same key starts fresh.
|
|
168
258
|
|
|
169
259
|
```js
|
|
@@ -173,9 +263,78 @@ const fn = withDedupe((req) => doFetch(req), { keyFn: (req) => req.url });
|
|
|
173
263
|
|
|
174
264
|
---
|
|
175
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
|
+
|
|
176
329
|
## Design notes
|
|
177
330
|
|
|
178
331
|
- **Zero dependencies.** Nothing to audit, nothing to break underneath you.
|
|
179
332
|
- **CJS + ESM.** `require()` and `import` both work.
|
|
180
333
|
- **Composable, not a client.** Every function takes and returns plain values (`Response`, plain objects, `Uint8Array`) — nothing proprietary to learn.
|
|
181
|
-
- **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,20 +1,26 @@
|
|
|
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": {
|
|
8
8
|
".": {
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
"import": {
|
|
10
|
+
"types": "./types/index.d.mts",
|
|
11
|
+
"default": "./src/index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"require": {
|
|
14
|
+
"types": "./types/index.d.ts",
|
|
15
|
+
"default": "./src/index.js"
|
|
16
|
+
}
|
|
12
17
|
},
|
|
13
18
|
"./package.json": "./package.json"
|
|
14
19
|
},
|
|
15
20
|
"sideEffects": false,
|
|
16
21
|
"scripts": {
|
|
17
|
-
"test": "node --test"
|
|
22
|
+
"test": "node --test",
|
|
23
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
18
24
|
},
|
|
19
25
|
"keywords": [
|
|
20
26
|
"http",
|
|
@@ -26,10 +32,24 @@
|
|
|
26
32
|
"retry-after",
|
|
27
33
|
"download-progress",
|
|
28
34
|
"error-normalization",
|
|
29
|
-
"utility"
|
|
35
|
+
"utility",
|
|
36
|
+
"limiter",
|
|
37
|
+
"concurrency",
|
|
38
|
+
"dedupe",
|
|
39
|
+
"http-client",
|
|
40
|
+
"fetch-wrapper",
|
|
41
|
+
"abort-signal"
|
|
30
42
|
],
|
|
31
43
|
"author": "Boy Offi9",
|
|
32
44
|
"license": "MIT",
|
|
45
|
+
"homepage": "https://github.com/boy-offi9-inc/reqkit#readme",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/boy-offi9-inc/reqkit.git"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/boy-offi9-inc/reqkit/issues"
|
|
52
|
+
},
|
|
33
53
|
"publishConfig": {
|
|
34
54
|
"access": "public"
|
|
35
55
|
},
|
|
@@ -40,6 +60,10 @@
|
|
|
40
60
|
"src",
|
|
41
61
|
"types",
|
|
42
62
|
"README.md",
|
|
63
|
+
"CHANGELOG.md",
|
|
43
64
|
"LICENSE"
|
|
44
|
-
]
|
|
65
|
+
],
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"typescript": "^5.6.0"
|
|
68
|
+
}
|
|
45
69
|
}
|
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 };
|
package/src/_sleep.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
class RetryAbortedError extends Error {
|
|
2
|
+
constructor(cause) {
|
|
3
|
+
super("Retry loop was aborted");
|
|
4
|
+
this.name = "RetryAbortedError";
|
|
5
|
+
if (cause !== undefined) this.cause = cause;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Abortable sleep shared by withRetry and retryAfterAware. Rejects with
|
|
11
|
+
* RetryAbortedError (carrying `cause`, usually the last failure) if the
|
|
12
|
+
* signal aborts before or during the wait.
|
|
13
|
+
*/
|
|
14
|
+
function sleep(ms, signal, cause) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
if (signal?.aborted) {
|
|
17
|
+
reject(new RetryAbortedError(cause));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const onAbort = () => {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
reject(new RetryAbortedError(cause));
|
|
23
|
+
};
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
signal?.removeEventListener("abort", onAbort);
|
|
26
|
+
resolve();
|
|
27
|
+
}, ms);
|
|
28
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { RetryAbortedError, sleep };
|
package/src/buildQuery.js
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
|
+
function toParam(key, value) {
|
|
2
|
+
if (value instanceof Date) {
|
|
3
|
+
if (Number.isNaN(value.getTime())) {
|
|
4
|
+
throw new TypeError(`buildQuery: value for "${key}" is an invalid Date`);
|
|
5
|
+
}
|
|
6
|
+
return value.toISOString();
|
|
7
|
+
}
|
|
8
|
+
if (typeof value === "object" || typeof value === "function" || typeof value === "symbol") {
|
|
9
|
+
throw new TypeError(
|
|
10
|
+
`buildQuery: value for "${key}" is not a primitive, Date or array of those — flatten it or JSON.stringify it first`
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
return String(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
1
16
|
/**
|
|
2
17
|
* Builds a query string from a params object. Skips null/undefined values,
|
|
3
|
-
* repeats the key for arrays (`?tag=a&tag=b`),
|
|
18
|
+
* repeats the key for arrays (`?tag=a&tag=b`), serializes Dates as ISO 8601,
|
|
19
|
+
* and encodes everything. Nested objects throw a TypeError instead of
|
|
20
|
+
* silently becoming "[object Object]".
|
|
4
21
|
*
|
|
5
22
|
* Deliberately minimal — for full query-string parsing/nested-object support,
|
|
6
23
|
* reach for `query-string` (sindresorhus) instead. This just covers the
|
|
@@ -15,13 +32,10 @@ function buildQuery(params) {
|
|
|
15
32
|
for (const [key, value] of Object.entries(params)) {
|
|
16
33
|
if (value === null || value === undefined) continue;
|
|
17
34
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
} else {
|
|
24
|
-
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
35
|
+
const values = Array.isArray(value) ? value : [value];
|
|
36
|
+
for (const v of values) {
|
|
37
|
+
if (v === null || v === undefined) continue;
|
|
38
|
+
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(toParam(key, v))}`);
|
|
25
39
|
}
|
|
26
40
|
}
|
|
27
41
|
|