@boy-offi9-inc/reqkit 0.1.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 +21 -0
- package/README.md +150 -0
- package/assets/logo-wordmark.svg +37 -0
- package/package.json +46 -0
- package/src/buildQuery.js +31 -0
- package/src/index.js +20 -0
- package/src/index.mjs +16 -0
- package/src/normalizeError.js +79 -0
- package/src/parseJsonSafe.js +26 -0
- package/src/retryAfterAware.js +90 -0
- package/src/withProgress.js +57 -0
- package/src/withRetry.js +52 -0
- package/src/withTimeout.js +41 -0
- package/types/index.d.ts +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Boy Offi9
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://www.npmjs.com/package/@boy-offi9-inc/reqkit" aria-label="npm">
|
|
3
|
+
<img src="./assets/logo-wordmark.svg" alt="reqkit on npm" width="480" />
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
9
|
+
<img src="https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js&logoColor=white" alt="Node >=18">
|
|
10
|
+
<img src="https://img.shields.io/badge/npm-%40boy--offi9--inc%2Freqkit-CB3837?logo=npm&logoColor=white" alt="npm package">
|
|
11
|
+
<img src="https://img.shields.io/badge/dependencies-zero-brightgreen" alt="Zero dependencies">
|
|
12
|
+
<img src="https://img.shields.io/badge/module-CJS%20%2B%20ESM-blue" alt="CJS + ESM">
|
|
13
|
+
<img src="https://img.shields.io/badge/types-included-3178C6?logo=typescript&logoColor=white" alt="TypeScript types included">
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
# reqkit
|
|
17
|
+
|
|
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; igno[...]
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
const { withRetry, normalizeError } = require("@boy-offi9-inc/reqkit");
|
|
22
|
+
|
|
23
|
+
const data = await withRetry(() => fetch(url).then(r => r.json()));
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Why this exists
|
|
29
|
+
|
|
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 o[...]
|
|
31
|
+
|
|
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[...]
|
|
33
|
+
|
|
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 ig[...]
|
|
35
|
+
- **`withProgress`** — progress callback for any streamed response body (downloads, large payloads, anything), not tied to a specific HTTP client.
|
|
36
|
+
|
|
37
|
+
Zero dependencies. CJS + ESM. TypeScript types included.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install @boy-offi9-inc/reqkit
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## API
|
|
50
|
+
|
|
51
|
+
### `withRetry(fn, opts?)`
|
|
52
|
+
|
|
53
|
+
Retries an async function with exponential backoff + jitter.
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
const data = await withRetry(
|
|
57
|
+
() => fetch(url).then(r => r.json()),
|
|
58
|
+
{ retries: 3, baseDelayMs: 300, onRetry: (err, attempt, delayMs) => console.log(`retry ${attempt} in ${delayMs}ms`) }
|
|
59
|
+
);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Option | Default | Description |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `retries` | `3` | max retry attempts after the first try |
|
|
65
|
+
| `baseDelayMs` | `300` | initial delay |
|
|
66
|
+
| `maxDelayMs` | `10000` | delay ceiling |
|
|
67
|
+
| `shouldRetry` | retries anything but AbortError | `(err, attempt) => boolean` |
|
|
68
|
+
| `onRetry` | — | `(err, attempt, delayMs) => void` |
|
|
69
|
+
|
|
70
|
+
### `withTimeout(fn, ms)`
|
|
71
|
+
|
|
72
|
+
Races an async function against a timeout, passing it an `AbortSignal` so the underlying request can actually be cancelled.
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
const data = await withTimeout(
|
|
76
|
+
signal => fetch(url, { signal }).then(r => r.json()),
|
|
77
|
+
5000
|
|
78
|
+
);
|
|
79
|
+
// throws TimeoutError on timeout, regardless of whether fn cooperates with the signal
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### `normalizeError(err)`
|
|
83
|
+
|
|
84
|
+
One consistent error shape regardless of source (fetch, axios, node http, generic):
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
const { message, status, code, isNetworkError, isTimeout } = normalizeError(err);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Every field is always present (`null`/`false` when not applicable) — no existence checks needed.
|
|
91
|
+
|
|
92
|
+
### `parseJsonSafe(input)`
|
|
93
|
+
|
|
94
|
+
Never throws on malformed JSON. Accepts a `Response` or a raw string.
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
const { data, error } = await parseJsonSafe(response);
|
|
98
|
+
if (error) { /* handle malformed JSON without a try/catch */ }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### `buildQuery(params)`
|
|
102
|
+
|
|
103
|
+
Minimal query string builder — skips `null`/`undefined`, repeats the key for arrays, encodes everything.
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
buildQuery({ q: "hello world", tag: ["a", "b"] });
|
|
107
|
+
// "q=hello%20world&tag=a&tag=b"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
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.
|
|
111
|
+
|
|
112
|
+
### `retryAfterAware(fn, opts?)`
|
|
113
|
+
|
|
114
|
+
Retries a fetch-like call, honoring `Retry-After`/`X-RateLimit-Reset` headers on 429/503 instead of blind backoff.
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
const response = await retryAfterAware(
|
|
118
|
+
() => fetch(url),
|
|
119
|
+
{ retries: 3, retryStatusCodes: [429, 503] }
|
|
120
|
+
);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Option | Default | Description |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| `retries` | `3` | |
|
|
126
|
+
| `retryStatusCodes` | `[429, 503]` | |
|
|
127
|
+
| `fallbackDelayMs` | `1000` | used when no usable header is present |
|
|
128
|
+
| `maxDelayMs` | `60000` | |
|
|
129
|
+
| `onRetry` | — | `(status, waitMs, attempt) => void` |
|
|
130
|
+
|
|
131
|
+
### `withProgress(response, onProgress?)`
|
|
132
|
+
|
|
133
|
+
Reads a streamed `Response` body while reporting progress, returns the collected bytes as a `Uint8Array`.
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
const bytes = await withProgress(response, ({ loaded, total, percent }) => {
|
|
137
|
+
console.log(`${percent ?? "?"}% (${loaded}/${total ?? "?"} bytes)`);
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
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 respon[...]
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Design notes
|
|
146
|
+
|
|
147
|
+
- **Zero dependencies.** Nothing to audit, nothing to break underneath you.
|
|
148
|
+
- **CJS + ESM.** `require()` and `import` both work.
|
|
149
|
+
- **Composable, not a client.** Every function takes and returns plain values (`Response`, plain objects, `Uint8Array`) — nothing proprietary to learn.
|
|
150
|
+
- **TypeScript types included**, hand-written (no build step to go wrong).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<svg width="640" height="160" viewBox="0 0 640 160" xmlns="http://www.w3.org/2000/svg">
|
|
3
|
+
<defs>
|
|
4
|
+
<linearGradient id="wm-bg" x1="0" y1="0" x2="640" y2="160" gradientUnits="userSpaceOnUse">
|
|
5
|
+
<stop offset="0" stop-color="#101825"/>
|
|
6
|
+
<stop offset="1" stop-color="#080c14"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
<linearGradient id="wm-icon-bg" x1="0" y1="0" x2="240" y2="240" gradientUnits="userSpaceOnUse">
|
|
9
|
+
<stop offset="0" stop-color="#16202e"/>
|
|
10
|
+
<stop offset="1" stop-color="#0c121c"/>
|
|
11
|
+
</linearGradient>
|
|
12
|
+
<linearGradient id="wm-arc" x1="175" y1="88" x2="98" y2="60" gradientUnits="userSpaceOnUse">
|
|
13
|
+
<stop offset="0" stop-color="#3d8bff"/>
|
|
14
|
+
<stop offset="1" stop-color="#ffb43d"/>
|
|
15
|
+
</linearGradient>
|
|
16
|
+
</defs>
|
|
17
|
+
|
|
18
|
+
<rect x="0" y="0" width="640" height="160" rx="20" fill="url(#wm-bg)"/>
|
|
19
|
+
|
|
20
|
+
<!-- icon, scaled down from the 240x240 mark -->
|
|
21
|
+
<g transform="translate(24,20) scale(0.5)">
|
|
22
|
+
<rect x="6" y="6" width="228" height="228" rx="46" fill="url(#wm-icon-bg)"/>
|
|
23
|
+
|
|
24
|
+
<path d="M 175.4 88 A 64 64 0 1 1 98.1 59.9"
|
|
25
|
+
fill="none" stroke="url(#wm-arc)" stroke-width="13" stroke-linecap="round"/>
|
|
26
|
+
|
|
27
|
+
<polygon points="113.1,54.4 103.1,67.7 96.9,50.8" fill="#ffb43d"/>
|
|
28
|
+
|
|
29
|
+
<circle cx="169" cy="161.1" r="7" fill="#3d8bff"/>
|
|
30
|
+
<circle cx="78.9" cy="169" r="6" fill="#6f9fff"/>
|
|
31
|
+
<circle cx="59.9" cy="98.1" r="5" fill="#a3c2ff"/>
|
|
32
|
+
</g>
|
|
33
|
+
|
|
34
|
+
<!-- wordmark -->
|
|
35
|
+
<text x="164" y="82" font-family="'JetBrains Mono', 'Courier New', monospace" font-size="42" font-weight="700" fill="#eef2f7">req<tspan fill="#ffb43d">kit</tspan></text>
|
|
36
|
+
<text x="165" y="112" font-family="'JetBrains Mono', 'Courier New', monospace" font-size="16" fill="#6b7688">HTTP helpers — retry, timeout, rate-limit aware</text>
|
|
37
|
+
</svg>
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@boy-offi9-inc/reqkit",
|
|
3
|
+
"version": "0.1.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.",
|
|
5
|
+
"main": "./src/index.js",
|
|
6
|
+
"types": "./types/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./types/index.d.ts",
|
|
10
|
+
"require": "./src/index.js",
|
|
11
|
+
"import": "./src/index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"http",
|
|
21
|
+
"fetch",
|
|
22
|
+
"retry",
|
|
23
|
+
"backoff",
|
|
24
|
+
"timeout",
|
|
25
|
+
"rate-limit",
|
|
26
|
+
"retry-after",
|
|
27
|
+
"download-progress",
|
|
28
|
+
"error-normalization",
|
|
29
|
+
"utility"
|
|
30
|
+
],
|
|
31
|
+
"author": "Boy Offi9",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"src",
|
|
41
|
+
"types",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"assets"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 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.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately minimal — for full query-string parsing/nested-object support,
|
|
6
|
+
* reach for `query-string` (sindresorhus) instead. This just covers the
|
|
7
|
+
* common "build a query string from a flat params object" case with zero deps.
|
|
8
|
+
*
|
|
9
|
+
* @param {Record<string, unknown>} params
|
|
10
|
+
*/
|
|
11
|
+
function buildQuery(params) {
|
|
12
|
+
if (!params) return "";
|
|
13
|
+
|
|
14
|
+
const pairs = [];
|
|
15
|
+
for (const [key, value] of Object.entries(params)) {
|
|
16
|
+
if (value === null || value === undefined) continue;
|
|
17
|
+
|
|
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))}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return pairs.join("&");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { buildQuery };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const { withRetry } = require("./withRetry");
|
|
2
|
+
const { withTimeout, TimeoutError } = require("./withTimeout");
|
|
3
|
+
const { normalizeError } = require("./normalizeError");
|
|
4
|
+
const { parseJsonSafe } = require("./parseJsonSafe");
|
|
5
|
+
const { buildQuery } = require("./buildQuery");
|
|
6
|
+
const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./retryAfterAware");
|
|
7
|
+
const { withProgress } = require("./withProgress");
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
withRetry,
|
|
11
|
+
withTimeout,
|
|
12
|
+
TimeoutError,
|
|
13
|
+
normalizeError,
|
|
14
|
+
parseJsonSafe,
|
|
15
|
+
buildQuery,
|
|
16
|
+
retryAfterAware,
|
|
17
|
+
parseRetryAfter,
|
|
18
|
+
getWaitMsFromHeaders,
|
|
19
|
+
withProgress,
|
|
20
|
+
};
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import reqkit from "./index.js";
|
|
2
|
+
|
|
3
|
+
export const {
|
|
4
|
+
withRetry,
|
|
5
|
+
withTimeout,
|
|
6
|
+
TimeoutError,
|
|
7
|
+
normalizeError,
|
|
8
|
+
parseJsonSafe,
|
|
9
|
+
buildQuery,
|
|
10
|
+
retryAfterAware,
|
|
11
|
+
parseRetryAfter,
|
|
12
|
+
getWaitMsFromHeaders,
|
|
13
|
+
withProgress,
|
|
14
|
+
} = reqkit;
|
|
15
|
+
|
|
16
|
+
export default reqkit;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes errors from fetch, axios, node's http/https, and generic
|
|
3
|
+
* Error objects into one consistent shape:
|
|
4
|
+
* { message, status, code, isNetworkError, isTimeout, cause }
|
|
5
|
+
*
|
|
6
|
+
* Every field is always present (null/false when not applicable) so callers
|
|
7
|
+
* can destructure without existence checks.
|
|
8
|
+
*/
|
|
9
|
+
function normalizeError(err) {
|
|
10
|
+
const base = {
|
|
11
|
+
message: "Unknown error",
|
|
12
|
+
status: null,
|
|
13
|
+
code: null,
|
|
14
|
+
isNetworkError: false,
|
|
15
|
+
isTimeout: false,
|
|
16
|
+
cause: err,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
if (!err) return base;
|
|
20
|
+
|
|
21
|
+
// axios error shape: err.response.{status,data}, err.request, err.code
|
|
22
|
+
if (err.isAxiosError || (err.response && typeof err.response.status === "number" && err.config)) {
|
|
23
|
+
return {
|
|
24
|
+
message: err.response?.data?.message || err.message || "Request failed",
|
|
25
|
+
status: err.response?.status ?? null,
|
|
26
|
+
code: err.code ?? null,
|
|
27
|
+
isNetworkError: !err.response && Boolean(err.request),
|
|
28
|
+
isTimeout: err.code === "ECONNABORTED" || /timeout/i.test(err.message || ""),
|
|
29
|
+
cause: err,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// fetch Response passed in directly (not thrown, but common enough to handle)
|
|
34
|
+
if (typeof err.status === "number" && typeof err.ok === "boolean") {
|
|
35
|
+
return {
|
|
36
|
+
message: err.statusText || `Request failed with status ${err.status}`,
|
|
37
|
+
status: err.status,
|
|
38
|
+
code: null,
|
|
39
|
+
isNetworkError: false,
|
|
40
|
+
isTimeout: false,
|
|
41
|
+
cause: err,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// our own TimeoutError, or DOMException from AbortController
|
|
46
|
+
if (err.name === "AbortError" || err.name === "TimeoutError" || err.code === "ETIMEDOUT") {
|
|
47
|
+
return {
|
|
48
|
+
message: err.message || "Request timed out",
|
|
49
|
+
status: null,
|
|
50
|
+
code: "ETIMEDOUT",
|
|
51
|
+
isNetworkError: false,
|
|
52
|
+
isTimeout: true,
|
|
53
|
+
cause: err,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// node http/https / undici network-level failures
|
|
58
|
+
if (err.code && /^E[A-Z]+$/.test(err.code)) {
|
|
59
|
+
return {
|
|
60
|
+
message: err.message || `Network error (${err.code})`,
|
|
61
|
+
status: null,
|
|
62
|
+
code: err.code,
|
|
63
|
+
isNetworkError: true,
|
|
64
|
+
isTimeout: false,
|
|
65
|
+
cause: err,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
message: err.message || String(err),
|
|
71
|
+
status: err.status ?? err.statusCode ?? null,
|
|
72
|
+
code: err.code ?? null,
|
|
73
|
+
isNetworkError: false,
|
|
74
|
+
isTimeout: false,
|
|
75
|
+
cause: err,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { normalizeError };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a fetch Response (or raw string) as JSON without throwing.
|
|
3
|
+
* Returns { data, error } — exactly one is non-null.
|
|
4
|
+
*
|
|
5
|
+
* @param {Response | string} input
|
|
6
|
+
*/
|
|
7
|
+
async function parseJsonSafe(input) {
|
|
8
|
+
let text;
|
|
9
|
+
try {
|
|
10
|
+
text = typeof input === "string" ? input : await input.text();
|
|
11
|
+
} catch (err) {
|
|
12
|
+
return { data: null, error: err };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!text) {
|
|
16
|
+
return { data: null, error: null };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return { data: JSON.parse(text), error: null };
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return { data: null, error: err };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { parseJsonSafe };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
function delay(ms) {
|
|
2
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parses a Retry-After header value into milliseconds. Per HTTP spec it's
|
|
7
|
+
* either an integer number of seconds, or an HTTP-date.
|
|
8
|
+
*/
|
|
9
|
+
function parseRetryAfter(headerValue) {
|
|
10
|
+
if (!headerValue) return null;
|
|
11
|
+
|
|
12
|
+
const seconds = Number(headerValue);
|
|
13
|
+
if (!Number.isNaN(seconds)) {
|
|
14
|
+
return Math.max(0, seconds * 1000);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const dateMs = Date.parse(headerValue);
|
|
18
|
+
if (!Number.isNaN(dateMs)) {
|
|
19
|
+
return Math.max(0, dateMs - Date.now());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reads a wait time from common rate-limit headers, trying Retry-After first,
|
|
27
|
+
* then falling back to X-RateLimit-Reset (epoch seconds or ms) if present.
|
|
28
|
+
*/
|
|
29
|
+
function getWaitMsFromHeaders(headers) {
|
|
30
|
+
if (!headers || typeof headers.get !== "function") return null;
|
|
31
|
+
|
|
32
|
+
const retryAfter = parseRetryAfter(headers.get("retry-after"));
|
|
33
|
+
if (retryAfter !== null) return retryAfter;
|
|
34
|
+
|
|
35
|
+
const resetHeader = headers.get("x-ratelimit-reset") || headers.get("x-rate-limit-reset");
|
|
36
|
+
if (resetHeader) {
|
|
37
|
+
const resetValue = Number(resetHeader);
|
|
38
|
+
if (!Number.isNaN(resetValue)) {
|
|
39
|
+
// Could be epoch seconds or epoch ms — treat values below a
|
|
40
|
+
// year-2100-in-seconds threshold as seconds.
|
|
41
|
+
const resetMs = resetValue > 4102444800 ? resetValue : resetValue * 1000;
|
|
42
|
+
return Math.max(0, resetMs - Date.now());
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Retries a fetch-like call, honoring Retry-After / X-RateLimit-Reset
|
|
51
|
+
* headers on 429/503 responses instead of blind exponential backoff — most
|
|
52
|
+
* generic retry libraries treat rate-limit responses like any other failure
|
|
53
|
+
* and ignore the header the server is explicitly giving you.
|
|
54
|
+
*
|
|
55
|
+
* `fn` should return a fetch Response (or anything with `.status` and
|
|
56
|
+
* `.headers.get()`).
|
|
57
|
+
*
|
|
58
|
+
* @param {() => Promise<Response>} fn
|
|
59
|
+
* @param {object} [opts]
|
|
60
|
+
* @param {number} [opts.retries=3]
|
|
61
|
+
* @param {number[]} [opts.retryStatusCodes=[429, 503]]
|
|
62
|
+
* @param {number} [opts.fallbackDelayMs=1000] - used when no usable header is present
|
|
63
|
+
* @param {number} [opts.maxDelayMs=60000]
|
|
64
|
+
* @param {(status: number, waitMs: number, attempt: number) => void} [opts.onRetry]
|
|
65
|
+
*/
|
|
66
|
+
async function retryAfterAware(fn, opts = {}) {
|
|
67
|
+
const {
|
|
68
|
+
retries = 3,
|
|
69
|
+
retryStatusCodes = [429, 503],
|
|
70
|
+
fallbackDelayMs = 1000,
|
|
71
|
+
maxDelayMs = 60000,
|
|
72
|
+
onRetry,
|
|
73
|
+
} = opts;
|
|
74
|
+
|
|
75
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
76
|
+
const response = await fn(attempt);
|
|
77
|
+
|
|
78
|
+
if (!retryStatusCodes.includes(response.status) || attempt === retries) {
|
|
79
|
+
return response;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const headerWaitMs = getWaitMsFromHeaders(response.headers);
|
|
83
|
+
const waitMs = Math.min(headerWaitMs ?? fallbackDelayMs * 2 ** attempt, maxDelayMs);
|
|
84
|
+
|
|
85
|
+
if (onRetry) onRetry(response.status, waitMs, attempt);
|
|
86
|
+
await delay(waitMs);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a fetch Response body while reporting progress, and returns the
|
|
3
|
+
* fully collected bytes as a Uint8Array. Works for any streamed response,
|
|
4
|
+
* not just file downloads — same shape whether it's a media file, a large
|
|
5
|
+
* JSON payload, or anything else worth showing progress for.
|
|
6
|
+
*
|
|
7
|
+
* If the server doesn't send Content-Length, `total` will be null in the
|
|
8
|
+
* progress callback — the loaded count is still accurate.
|
|
9
|
+
*
|
|
10
|
+
* @param {Response} response
|
|
11
|
+
* @param {(progress: { loaded: number, total: number | null, percent: number | null }) => void} onProgress
|
|
12
|
+
* @returns {Promise<Uint8Array>}
|
|
13
|
+
*/
|
|
14
|
+
async function withProgress(response, onProgress) {
|
|
15
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
16
|
+
// No streaming support available — fall back to a single-shot read,
|
|
17
|
+
// still reporting one final progress event so callers don't need a branch.
|
|
18
|
+
const buf = new Uint8Array(await response.arrayBuffer());
|
|
19
|
+
if (onProgress) {
|
|
20
|
+
onProgress({ loaded: buf.byteLength, total: buf.byteLength, percent: 100 });
|
|
21
|
+
}
|
|
22
|
+
return buf;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const contentLength = response.headers?.get?.("content-length");
|
|
26
|
+
const total = contentLength ? Number(contentLength) : null;
|
|
27
|
+
|
|
28
|
+
const reader = response.body.getReader();
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let loaded = 0;
|
|
31
|
+
|
|
32
|
+
for (;;) {
|
|
33
|
+
const { done, value } = await reader.read();
|
|
34
|
+
if (done) break;
|
|
35
|
+
|
|
36
|
+
chunks.push(value);
|
|
37
|
+
loaded += value.byteLength;
|
|
38
|
+
|
|
39
|
+
if (onProgress) {
|
|
40
|
+
onProgress({
|
|
41
|
+
loaded,
|
|
42
|
+
total,
|
|
43
|
+
percent: total ? Math.round((loaded / total) * 100) : null,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result = new Uint8Array(loaded);
|
|
49
|
+
let offset = 0;
|
|
50
|
+
for (const chunk of chunks) {
|
|
51
|
+
result.set(chunk, offset);
|
|
52
|
+
offset += chunk.byteLength;
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { withProgress };
|
package/src/withRetry.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
function defaultShouldRetry(err) {
|
|
2
|
+
// Retry network-level failures by default. HTTP status handling is the
|
|
3
|
+
// caller's job (they know what "failure" means for their response shape) —
|
|
4
|
+
// see retryAfterAware.js for a version that understands status codes.
|
|
5
|
+
return Boolean(err) && err.name !== "AbortError";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function delay(ms) {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Retries an async function with exponential backoff + jitter.
|
|
14
|
+
*
|
|
15
|
+
* @param {() => Promise<any>} fn
|
|
16
|
+
* @param {object} [opts]
|
|
17
|
+
* @param {number} [opts.retries=3] - max retry attempts after the first try
|
|
18
|
+
* @param {number} [opts.baseDelayMs=300] - initial delay
|
|
19
|
+
* @param {number} [opts.maxDelayMs=10000] - delay ceiling
|
|
20
|
+
* @param {(err: any, attempt: number) => boolean} [opts.shouldRetry]
|
|
21
|
+
* @param {(err: any, attempt: number, delayMs: number) => void} [opts.onRetry]
|
|
22
|
+
*/
|
|
23
|
+
async function withRetry(fn, opts = {}) {
|
|
24
|
+
const {
|
|
25
|
+
retries = 3,
|
|
26
|
+
baseDelayMs = 300,
|
|
27
|
+
maxDelayMs = 10000,
|
|
28
|
+
shouldRetry = defaultShouldRetry,
|
|
29
|
+
onRetry,
|
|
30
|
+
} = opts;
|
|
31
|
+
|
|
32
|
+
let lastErr;
|
|
33
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
34
|
+
try {
|
|
35
|
+
return await fn(attempt);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
lastErr = err;
|
|
38
|
+
const isLastAttempt = attempt === retries;
|
|
39
|
+
if (isLastAttempt || !shouldRetry(err, attempt)) {
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
const exponential = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
43
|
+
const jitter = Math.random() * exponential * 0.2;
|
|
44
|
+
const waitMs = Math.round(exponential + jitter);
|
|
45
|
+
if (onRetry) onRetry(err, attempt, waitMs);
|
|
46
|
+
await delay(waitMs);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
throw lastErr;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { withRetry };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
class TimeoutError extends Error {
|
|
2
|
+
constructor(ms) {
|
|
3
|
+
super(`Timed out after ${ms}ms`);
|
|
4
|
+
this.name = "TimeoutError";
|
|
5
|
+
this.code = "ETIMEDOUT";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
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)`.
|
|
13
|
+
*
|
|
14
|
+
* @param {(signal: AbortSignal) => Promise<any>} fn
|
|
15
|
+
* @param {number} ms
|
|
16
|
+
*/
|
|
17
|
+
async function withTimeout(fn, ms) {
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
20
|
+
|
|
21
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
22
|
+
controller.signal.addEventListener("abort", () => reject(new TimeoutError(ms)));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
return await Promise.race([fn(controller.signal), timeoutPromise]);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
// 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
|
+
}
|
|
35
|
+
throw err;
|
|
36
|
+
} finally {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { withTimeout, TimeoutError };
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface RetryOptions {
|
|
2
|
+
retries?: number;
|
|
3
|
+
baseDelayMs?: number;
|
|
4
|
+
maxDelayMs?: number;
|
|
5
|
+
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
|
6
|
+
onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function withRetry<T>(fn: (attempt: number) => Promise<T>, opts?: RetryOptions): Promise<T>;
|
|
10
|
+
|
|
11
|
+
export class TimeoutError extends Error {
|
|
12
|
+
code: "ETIMEDOUT";
|
|
13
|
+
constructor(ms: number);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function withTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T>;
|
|
17
|
+
|
|
18
|
+
export interface NormalizedError {
|
|
19
|
+
message: string;
|
|
20
|
+
status: number | null;
|
|
21
|
+
code: string | null;
|
|
22
|
+
isNetworkError: boolean;
|
|
23
|
+
isTimeout: boolean;
|
|
24
|
+
cause: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeError(err: unknown): NormalizedError;
|
|
28
|
+
|
|
29
|
+
export interface JsonSafeResult<T = unknown> {
|
|
30
|
+
data: T | null;
|
|
31
|
+
error: Error | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseJsonSafe<T = unknown>(input: Response | string): Promise<JsonSafeResult<T>>;
|
|
35
|
+
|
|
36
|
+
export function buildQuery(params: Record<string, unknown> | null | undefined): string;
|
|
37
|
+
|
|
38
|
+
export interface RetryAfterAwareOptions {
|
|
39
|
+
retries?: number;
|
|
40
|
+
retryStatusCodes?: number[];
|
|
41
|
+
fallbackDelayMs?: number;
|
|
42
|
+
maxDelayMs?: number;
|
|
43
|
+
onRetry?: (status: number, waitMs: number, attempt: number) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function retryAfterAware(
|
|
47
|
+
fn: (attempt: number) => Promise<Response>,
|
|
48
|
+
opts?: RetryAfterAwareOptions
|
|
49
|
+
): Promise<Response>;
|
|
50
|
+
|
|
51
|
+
export function parseRetryAfter(headerValue: string | null): number | null;
|
|
52
|
+
export function getWaitMsFromHeaders(headers: Headers | null | undefined): number | null;
|
|
53
|
+
|
|
54
|
+
export interface DownloadProgress {
|
|
55
|
+
loaded: number;
|
|
56
|
+
total: number | null;
|
|
57
|
+
percent: number | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function withProgress(
|
|
61
|
+
response: Response,
|
|
62
|
+
onProgress?: (progress: DownloadProgress) => void
|
|
63
|
+
): Promise<Uint8Array>;
|