@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 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
- Small, composable HTTP helper functions. Not a client, not an axios replacement — works alongside `fetch`, `axios`, or anything else you're already using. Pull in the one function you need; ignore the rest.
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
- const { withRetry, normalizeError } = require("@boy-offi9-inc/reqkit");
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
- const data = await withRetry(() => fetch(url).then(r => r.json()));
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
- ## Why this exists
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
- 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, and a couple of small helpers that you can pick up independently.
70
+ ## Why this exists
31
71
 
32
- **Honest note:** `withRetry`, `withTimeout`, and `normalizeError` solve a well-covered problem — there are other solid packages doing similar things (`fetchy`, `fetchpilot`, `p-retry`, to name a few).
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
- - **`retryAfterAware`** — actually reads `Retry-After`/`X-RateLimit-Reset` headers instead of blind backoff on 429s. Most retry libraries treat rate-limit responses like any other failure and ignore the server's suggested retry time.
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
- Zero dependencies. CJS + ESM. TypeScript types included.
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` | delay ceiling |
68
- | `shouldRetry` | retries anything but AbortError | `(err, attempt) => boolean` |
69
- | `onRetry` | — | `(err, attempt, delayMs) => void` |
70
- | `signal` | — | `AbortSignal` — cancels the whole retry loop, including any pending backoff wait, throwing `RetryAbortedError` |
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
- > **Heads up:** `fetch` doesn't throw on 4xx/5xx, so `withRetry` alone won't retry a `500`. Throw inside `fn` for the statuses you care about (`if (!r.ok) throw ...`), or use `retryAfterAware` below, which retries on status codes.
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
- Retries a fetch-like call, honoring `Retry-After`/`X-RateLimit-Reset` headers on 429/503 instead of blind backoff.
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.2.0",
4
- "description": "Small, composable HTTP helper functions — retry with backoff, timeouts, normalized errors, safe JSON parsing, rate-limit-aware retry, and download progress. Not a client, works alongside fetch/axios/anything.",
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;
@@ -1,75 +1,23 @@
1
- const { RetryAbortedError, sleep } = require("./_sleep");
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
- async function retryAfterAware(fn, opts = {}) {
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
- let lastErr;
108
- for (let attempt = 0; attempt <= retries; attempt++) {
109
- if (signal?.aborted) throw new RetryAbortedError(lastErr);
110
-
111
- let response;
112
- try {
113
- response = await fn(attempt);
114
- } catch (err) {
115
- lastErr = err;
116
- if (attempt === retries || !shouldRetryError(err, attempt)) throw err;
117
-
118
- const waitMs = Math.min(fallbackDelayMs * 2 ** attempt, maxDelayMs);
119
- if (onRetryError) onRetryError(err, waitMs, attempt);
120
- await sleep(waitMs, signal, err);
121
- continue;
122
- }
123
-
124
- if (!retryStatusCodes.includes(response.status) || attempt === retries) {
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 network-level failures by default. HTTP status handling is the
5
- // caller's job (they know what "failure" means for their response shape) —
6
- // see retryAfterAware.js for a version that understands status codes.
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
- * Retries an async function with exponential backoff + jitter.
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] - delay ceiling
18
- * @param {(err: any, attempt: number) => boolean} [opts.shouldRetry]
19
- * @param {(err: any, attempt: number, delayMs: number) => void} [opts.onRetry]
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. This is
22
- * separate from a per-call AbortSignal you might pass into `fn` yourself —
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; attempt <= retries; attempt++) {
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
- return await fn(attempt);
117
+ result = await fn(attempt);
42
118
  } catch (err) {
119
+ if (err instanceof StopRetry) throw err.original ?? err;
43
120
  lastErr = err;
44
- const isLastAttempt = attempt === retries;
45
- if (isLastAttempt || !shouldRetry(err, attempt)) {
46
- throw err;
47
- }
48
- const exponential = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
49
- const jitter = Math.random() * exponential * 0.2;
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 };
@@ -7,34 +7,62 @@ class TimeoutError extends Error {
7
7
  }
8
8
 
9
9
  /**
10
- * Races an async function against a timeout. If `fn` accepts an AbortSignal
11
- * as its argument, it's passed one so the underlying request can actually be
12
- * cancelled (not just abandoned) — e.g. `withTimeout(signal => fetch(url, { signal }), 5000)`.
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
- const timer = setTimeout(() => controller.abort(), ms);
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 timeoutPromise = new Promise((_, reject) => {
22
- controller.signal.addEventListener("abort", () => reject(new TimeoutError(ms)));
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), timeoutPromise]);
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 timeout promise "won"
30
- // the race (that's a listener-registration-order detail callers shouldn't
31
- // have to think about).
32
- if (controller.signal.aborted) {
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
- onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
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 function withTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T>;
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;