@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Boy Offi9
3
+ Copyright (c) 2026 Boy Offi9 Inc
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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) |
29
67
 
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.
68
+ ---
31
69
 
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).
70
+ ## Why this exists
33
71
 
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.
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
- Zero dependencies. CJS + ESM. TypeScript types included.
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` | 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,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
- ### `withTimeout(fn, ms)`
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
- 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.
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
- ### `withProgress(response, onProgress?)`
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.1.7",
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": {
8
8
  ".": {
9
- "types": "./types/index.d.ts",
10
- "require": "./src/index.js",
11
- "import": "./src/index.mjs"
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`), and encodes everything.
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
- if (Array.isArray(value)) {
19
- for (const v of value) {
20
- if (v === null || v === undefined) continue;
21
- pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
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