@boy-offi9-inc/reqkit 0.1.2 → 0.1.7
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 +31 -0
- package/package.json +2 -3
- package/src/index.js +4 -1
- package/src/index.mjs +2 -0
- package/src/withDedupe.js +50 -0
- package/src/withRetry.js +36 -5
- package/types/index.d.ts +14 -0
package/README.md
CHANGED
|
@@ -33,6 +33,7 @@ Most HTTP retry/timeout wrappers bundle everything into a client you have to ful
|
|
|
33
33
|
|
|
34
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
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.
|
|
36
37
|
|
|
37
38
|
Zero dependencies. CJS + ESM. TypeScript types included.
|
|
38
39
|
|
|
@@ -66,6 +67,14 @@ const data = await withRetry(
|
|
|
66
67
|
| `maxDelayMs` | `10000` | delay ceiling |
|
|
67
68
|
| `shouldRetry` | retries anything but AbortError | `(err, attempt) => boolean` |
|
|
68
69
|
| `onRetry` | — | `(err, attempt, delayMs) => void` |
|
|
70
|
+
| `signal` | — | `AbortSignal` — cancels the whole retry loop, including any pending backoff wait, throwing `RetryAbortedError` |
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
// Cancel a long retry/backoff sequence externally — e.g. the user navigated away
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const promise = withRetry(() => fetch(url), { retries: 5, signal: controller.signal });
|
|
76
|
+
controller.abort(); // rejects with RetryAbortedError, even mid-backoff
|
|
77
|
+
```
|
|
69
78
|
|
|
70
79
|
### `withTimeout(fn, ms)`
|
|
71
80
|
|
|
@@ -140,6 +149,28 @@ const bytes = await withProgress(response, ({ loaded, total, percent }) => {
|
|
|
140
149
|
|
|
141
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.
|
|
142
151
|
|
|
152
|
+
### `withDedupe(fn, opts?)`
|
|
153
|
+
|
|
154
|
+
Coalesces concurrent calls with the same arguments into a single in-flight call — every caller gets the same result, but the underlying request only fires once. Useful when several parts of a UI ask for the same resource around the same time.
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
const getUser = withDedupe((id) => fetch(`/api/users/${id}`).then(r => r.json()));
|
|
158
|
+
|
|
159
|
+
// Only one network request goes out — both callers share it.
|
|
160
|
+
const [a, b] = await Promise.all([getUser(1), getUser(1)]);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
| Option | Default | Notes |
|
|
164
|
+
| --- | --- | --- |
|
|
165
|
+
| `keyFn` | `(...args) => JSON.stringify(args)` | derives the dedupe key from call args |
|
|
166
|
+
|
|
167
|
+
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
|
+
|
|
169
|
+
```js
|
|
170
|
+
// Custom key when the default arg-based key isn't quite right
|
|
171
|
+
const fn = withDedupe((req) => doFetch(req), { keyFn: (req) => req.url });
|
|
172
|
+
```
|
|
173
|
+
|
|
143
174
|
---
|
|
144
175
|
|
|
145
176
|
## Design notes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boy-offi9-inc/reqkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
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
5
|
"main": "./src/index.js",
|
|
6
6
|
"types": "./types/index.d.ts",
|
|
@@ -40,7 +40,6 @@
|
|
|
40
40
|
"src",
|
|
41
41
|
"types",
|
|
42
42
|
"README.md",
|
|
43
|
-
"LICENSE"
|
|
44
|
-
"assets"
|
|
43
|
+
"LICENSE"
|
|
45
44
|
]
|
|
46
45
|
}
|
package/src/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
const { withRetry } = require("./withRetry");
|
|
1
|
+
const { withRetry, RetryAbortedError } = require("./withRetry");
|
|
2
2
|
const { withTimeout, TimeoutError } = require("./withTimeout");
|
|
3
3
|
const { normalizeError } = require("./normalizeError");
|
|
4
4
|
const { parseJsonSafe } = require("./parseJsonSafe");
|
|
5
5
|
const { buildQuery } = require("./buildQuery");
|
|
6
6
|
const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./retryAfterAware");
|
|
7
7
|
const { withProgress } = require("./withProgress");
|
|
8
|
+
const { withDedupe } = require("./withDedupe");
|
|
8
9
|
|
|
9
10
|
module.exports = {
|
|
10
11
|
withRetry,
|
|
12
|
+
RetryAbortedError,
|
|
11
13
|
withTimeout,
|
|
12
14
|
TimeoutError,
|
|
13
15
|
normalizeError,
|
|
@@ -17,4 +19,5 @@ module.exports = {
|
|
|
17
19
|
parseRetryAfter,
|
|
18
20
|
getWaitMsFromHeaders,
|
|
19
21
|
withProgress,
|
|
22
|
+
withDedupe,
|
|
20
23
|
};
|
package/src/index.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import reqkit from "./index.js";
|
|
|
2
2
|
|
|
3
3
|
export const {
|
|
4
4
|
withRetry,
|
|
5
|
+
RetryAbortedError,
|
|
5
6
|
withTimeout,
|
|
6
7
|
TimeoutError,
|
|
7
8
|
normalizeError,
|
|
@@ -11,6 +12,7 @@ export const {
|
|
|
11
12
|
parseRetryAfter,
|
|
12
13
|
getWaitMsFromHeaders,
|
|
13
14
|
withProgress,
|
|
15
|
+
withDedupe,
|
|
14
16
|
} = reqkit;
|
|
15
17
|
|
|
16
18
|
export default reqkit;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
function defaultKeyFn(...args) {
|
|
2
|
+
try {
|
|
3
|
+
return JSON.stringify(args);
|
|
4
|
+
} catch {
|
|
5
|
+
// Fall back to a stable-ish key if args aren't JSON-serializable
|
|
6
|
+
// (e.g. contain a function or circular ref).
|
|
7
|
+
return args.map((a) => String(a)).join("|");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wraps an async function so that concurrent calls with the same key share
|
|
13
|
+
* a single in-flight promise instead of firing duplicate requests. Common
|
|
14
|
+
* use case: several parts of a UI ask for the same resource at once (e.g. a
|
|
15
|
+
* user profile) — only one network request actually goes out, and every
|
|
16
|
+
* caller resolves with the same result.
|
|
17
|
+
*
|
|
18
|
+
* The in-flight entry is cleared as soon as the call settles (success or
|
|
19
|
+
* failure), so the next call — even with the same key — always starts a
|
|
20
|
+
* fresh request. This is deduplication of concurrent calls, not a cache.
|
|
21
|
+
*
|
|
22
|
+
* @param {(...args: any[]) => Promise<any>} fn
|
|
23
|
+
* @param {object} [opts]
|
|
24
|
+
* @param {(...args: any[]) => string} [opts.keyFn] - derives a dedupe key
|
|
25
|
+
* from the call arguments. Defaults to JSON.stringify(args).
|
|
26
|
+
* @returns {(...args: any[]) => Promise<any>}
|
|
27
|
+
*/
|
|
28
|
+
function withDedupe(fn, opts = {}) {
|
|
29
|
+
const { keyFn = defaultKeyFn } = opts;
|
|
30
|
+
const inFlight = new Map();
|
|
31
|
+
|
|
32
|
+
return function deduped(...args) {
|
|
33
|
+
const key = keyFn(...args);
|
|
34
|
+
|
|
35
|
+
if (inFlight.has(key)) {
|
|
36
|
+
return inFlight.get(key);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const promise = Promise.resolve()
|
|
40
|
+
.then(() => fn(...args))
|
|
41
|
+
.finally(() => {
|
|
42
|
+
inFlight.delete(key);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
inFlight.set(key, promise);
|
|
46
|
+
return promise;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { withDedupe };
|
package/src/withRetry.js
CHANGED
|
@@ -5,20 +5,47 @@ function defaultShouldRetry(err) {
|
|
|
5
5
|
return Boolean(err) && err.name !== "AbortError";
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
class RetryAbortedError extends Error {
|
|
9
|
+
constructor() {
|
|
10
|
+
super("Retry loop was aborted");
|
|
11
|
+
this.name = "RetryAbortedError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function delay(ms, signal) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
if (signal?.aborted) {
|
|
18
|
+
reject(new RetryAbortedError());
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const timer = setTimeout(resolve, ms);
|
|
22
|
+
if (signal) {
|
|
23
|
+
signal.addEventListener(
|
|
24
|
+
"abort",
|
|
25
|
+
() => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
reject(new RetryAbortedError());
|
|
28
|
+
},
|
|
29
|
+
{ once: true }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
10
33
|
}
|
|
11
34
|
|
|
12
35
|
/**
|
|
13
36
|
* Retries an async function with exponential backoff + jitter.
|
|
14
37
|
*
|
|
15
|
-
* @param {() => Promise<any>} fn
|
|
38
|
+
* @param {(attempt: number) => Promise<any>} fn
|
|
16
39
|
* @param {object} [opts]
|
|
17
40
|
* @param {number} [opts.retries=3] - max retry attempts after the first try
|
|
18
41
|
* @param {number} [opts.baseDelayMs=300] - initial delay
|
|
19
42
|
* @param {number} [opts.maxDelayMs=10000] - delay ceiling
|
|
20
43
|
* @param {(err: any, attempt: number) => boolean} [opts.shouldRetry]
|
|
21
44
|
* @param {(err: any, attempt: number, delayMs: number) => void} [opts.onRetry]
|
|
45
|
+
* @param {AbortSignal} [opts.signal] - cancels the whole retry loop,
|
|
46
|
+
* including any pending backoff wait, throwing RetryAbortedError. This is
|
|
47
|
+
* separate from a per-call AbortSignal you might pass into `fn` yourself —
|
|
48
|
+
* this one governs whether *another attempt starts at all*.
|
|
22
49
|
*/
|
|
23
50
|
async function withRetry(fn, opts = {}) {
|
|
24
51
|
const {
|
|
@@ -27,10 +54,14 @@ async function withRetry(fn, opts = {}) {
|
|
|
27
54
|
maxDelayMs = 10000,
|
|
28
55
|
shouldRetry = defaultShouldRetry,
|
|
29
56
|
onRetry,
|
|
57
|
+
signal,
|
|
30
58
|
} = opts;
|
|
31
59
|
|
|
32
60
|
let lastErr;
|
|
33
61
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
62
|
+
if (signal?.aborted) {
|
|
63
|
+
throw new RetryAbortedError();
|
|
64
|
+
}
|
|
34
65
|
try {
|
|
35
66
|
return await fn(attempt);
|
|
36
67
|
} catch (err) {
|
|
@@ -43,10 +74,10 @@ async function withRetry(fn, opts = {}) {
|
|
|
43
74
|
const jitter = Math.random() * exponential * 0.2;
|
|
44
75
|
const waitMs = Math.round(exponential + jitter);
|
|
45
76
|
if (onRetry) onRetry(err, attempt, waitMs);
|
|
46
|
-
await delay(waitMs);
|
|
77
|
+
await delay(waitMs, signal);
|
|
47
78
|
}
|
|
48
79
|
}
|
|
49
80
|
throw lastErr;
|
|
50
81
|
}
|
|
51
82
|
|
|
52
|
-
module.exports = { withRetry };
|
|
83
|
+
module.exports = { withRetry, RetryAbortedError };
|
package/types/index.d.ts
CHANGED
|
@@ -4,6 +4,11 @@ export interface RetryOptions {
|
|
|
4
4
|
maxDelayMs?: number;
|
|
5
5
|
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
|
6
6
|
onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class RetryAbortedError extends Error {
|
|
11
|
+
name: "RetryAbortedError";
|
|
7
12
|
}
|
|
8
13
|
|
|
9
14
|
export function withRetry<T>(fn: (attempt: number) => Promise<T>, opts?: RetryOptions): Promise<T>;
|
|
@@ -61,3 +66,12 @@ export function withProgress(
|
|
|
61
66
|
response: Response,
|
|
62
67
|
onProgress?: (progress: DownloadProgress) => void
|
|
63
68
|
): Promise<Uint8Array>;
|
|
69
|
+
|
|
70
|
+
export interface DedupeOptions {
|
|
71
|
+
keyFn?: (...args: any[]) => string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function withDedupe<Fn extends (...args: any[]) => Promise<any>>(
|
|
75
|
+
fn: Fn,
|
|
76
|
+
opts?: DedupeOptions
|
|
77
|
+
): Fn;
|