@boy-offi9-inc/reqkit 0.2.0 → 0.5.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 +305 -21
- package/RECIPES.md +243 -0
- package/dist/reqkit.mjs +2304 -0
- package/package.json +31 -4
- package/src/HTTPError.js +14 -0
- package/src/_cache.js +55 -0
- package/src/_http.js +90 -0
- package/src/_sleep.js +20 -1
- package/src/createCircuitBreaker.js +124 -0
- package/src/createFetch.js +398 -0
- package/src/createLimiter.js +55 -0
- package/src/createMemoryCache.js +40 -0
- package/src/createMockFetch.js +184 -0
- package/src/createRateLimiter.js +111 -0
- package/src/downloadResumable.js +193 -0
- package/src/index.js +22 -1
- package/src/index.mjs +13 -0
- package/src/retryAfterAware.js +26 -88
- package/src/streams.js +149 -0
- package/src/withRetry.js +112 -23
- package/src/withTimeout.js +43 -15
- package/types/index.d.ts +308 -2
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const { abortableSleep } = require("./_sleep");
|
|
2
|
+
const { getRateLimitInfo } = require("./_http");
|
|
3
|
+
|
|
4
|
+
function abortable(promise, signal) {
|
|
5
|
+
if (!signal) return promise;
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
if (signal.aborted) {
|
|
8
|
+
reject(signal.reason);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const onAbort = () => reject(signal.reason);
|
|
12
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
13
|
+
promise.then(
|
|
14
|
+
(v) => (signal.removeEventListener("abort", onAbort), resolve(v)),
|
|
15
|
+
(e) => (signal.removeEventListener("abort", onAbort), reject(e))
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A *proactive* rate limiter: slows you down before the server has to say
|
|
22
|
+
* 429. Two things feed it:
|
|
23
|
+
* 1. your own budget — `requests` per `perMs` (sliding window), and
|
|
24
|
+
* 2. what the server tells you — `update(response)` reads X-RateLimit-* /
|
|
25
|
+
* RateLimit-* / Retry-After and pauses everything once `remaining` hits 0
|
|
26
|
+
* (or the server sent Retry-After), until the reset time.
|
|
27
|
+
*
|
|
28
|
+
* Callers are served FIFO.
|
|
29
|
+
*
|
|
30
|
+
* ```js
|
|
31
|
+
* const rl = createRateLimiter({ requests: 10, perMs: 1000 });
|
|
32
|
+
* const res = await rl.run(() => fetch(url)); // acquire → call → learn from headers
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @param {object} [opts]
|
|
36
|
+
* @param {number} [opts.requests=Infinity] - max requests per window
|
|
37
|
+
* @param {number} [opts.perMs=1000] - window length
|
|
38
|
+
*/
|
|
39
|
+
function createRateLimiter(opts = {}) {
|
|
40
|
+
const { requests = Infinity, perMs = 1000 } = opts;
|
|
41
|
+
if (requests !== Infinity && !(Number.isInteger(requests) && requests >= 1)) {
|
|
42
|
+
throw new TypeError("createRateLimiter: `requests` must be an integer >= 1 (or Infinity)");
|
|
43
|
+
}
|
|
44
|
+
if (!(typeof perMs === "number" && perMs > 0 && Number.isFinite(perMs))) {
|
|
45
|
+
throw new TypeError("createRateLimiter: `perMs` must be a positive number");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const stamps = [];
|
|
49
|
+
let blockedUntil = 0;
|
|
50
|
+
let tail = Promise.resolve();
|
|
51
|
+
|
|
52
|
+
async function take(signal) {
|
|
53
|
+
for (;;) {
|
|
54
|
+
signal?.throwIfAborted?.();
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
while (stamps.length > 0 && stamps[0] <= now - perMs) stamps.shift();
|
|
57
|
+
|
|
58
|
+
let waitMs = 0;
|
|
59
|
+
if (blockedUntil > now) waitMs = blockedUntil - now;
|
|
60
|
+
else if (stamps.length >= requests) waitMs = stamps[0] + perMs - now;
|
|
61
|
+
|
|
62
|
+
if (waitMs <= 0) {
|
|
63
|
+
stamps.push(now);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await abortableSleep(waitMs, signal);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Resolves when it's your turn. Rejects with the signal's reason if aborted while waiting. */
|
|
71
|
+
function acquire({ signal } = {}) {
|
|
72
|
+
const turn = tail.then(() => take(signal));
|
|
73
|
+
tail = turn.catch(() => {});
|
|
74
|
+
return abortable(turn, signal);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Feed a response (anything with `.headers`/`.status`) so the limiter learns the server's limits. */
|
|
78
|
+
function update(response) {
|
|
79
|
+
if (!response || !response.headers) return;
|
|
80
|
+
const { remaining, resetMs, retryAfterMs } = getRateLimitInfo(response.headers);
|
|
81
|
+
let blockMs = null;
|
|
82
|
+
if (retryAfterMs !== null && (response.status === 429 || response.status === 503)) blockMs = retryAfterMs;
|
|
83
|
+
else if (remaining === 0 && resetMs !== null) blockMs = resetMs;
|
|
84
|
+
if (blockMs !== null) blockedUntil = Math.max(blockedUntil, Date.now() + blockMs);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** acquire → fn() → update(result). Errors thrown by fn propagate (the slot stays spent). */
|
|
88
|
+
async function run(fn, { signal } = {}) {
|
|
89
|
+
await acquire({ signal });
|
|
90
|
+
const result = await fn();
|
|
91
|
+
update(result);
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
acquire,
|
|
97
|
+
update,
|
|
98
|
+
run,
|
|
99
|
+
/** Ms until the server-imposed pause ends (0 if none). */
|
|
100
|
+
get blockedForMs() {
|
|
101
|
+
return Math.max(0, blockedUntil - Date.now());
|
|
102
|
+
},
|
|
103
|
+
/** Forget all history and server hints. */
|
|
104
|
+
reset() {
|
|
105
|
+
stamps.length = 0;
|
|
106
|
+
blockedUntil = 0;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { createRateLimiter };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const { HTTPError } = require("./HTTPError");
|
|
2
|
+
const { BodyTooLargeError } = require("./withProgress");
|
|
3
|
+
const { normalizeError } = require("./normalizeError");
|
|
4
|
+
const { getWaitMsFromHeaders } = require("./_http");
|
|
5
|
+
const { abortableSleep } = require("./_sleep");
|
|
6
|
+
|
|
7
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
8
|
+
|
|
9
|
+
function parseContentRange(header) {
|
|
10
|
+
// "bytes 100-199/1000" (total may be "*")
|
|
11
|
+
const m = /^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i.exec(String(header || "").trim());
|
|
12
|
+
if (!m) return null;
|
|
13
|
+
return { start: Number(m[1]), end: Number(m[2]), total: m[3] === "*" ? null : Number(m[3]) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isRetryable(err) {
|
|
17
|
+
if (err instanceof BodyTooLargeError) return false;
|
|
18
|
+
if (err instanceof HTTPError) return RETRYABLE_STATUS.has(err.status);
|
|
19
|
+
const n = normalizeError(err);
|
|
20
|
+
if (n.isAborted) return false;
|
|
21
|
+
return n.isNetworkError || n.isTimeout;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Downloads a URL into memory and, if the connection drops mid-body, resumes
|
|
26
|
+
* from where it stopped with an HTTP `Range` request instead of starting
|
|
27
|
+
* over. Uses `If-Range` with the first response's strong ETag (or
|
|
28
|
+
* Last-Modified) so a file that changed on the server is restarted rather
|
|
29
|
+
* than stitched together wrongly. If the server ignores Range, it simply
|
|
30
|
+
* restarts from zero.
|
|
31
|
+
*
|
|
32
|
+
* `retries` counts consecutive attempts *without progress* — a flaky link
|
|
33
|
+
* that keeps making headway keeps going. Honors Retry-After on 429/503.
|
|
34
|
+
*
|
|
35
|
+
* ```js
|
|
36
|
+
* const bytes = await downloadResumable(url, {
|
|
37
|
+
* onProgress: ({ percent }) => console.log(percent),
|
|
38
|
+
* maxBytes: 200 * 1024 * 1024,
|
|
39
|
+
* });
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* @param {string} url
|
|
43
|
+
* @param {object} [opts]
|
|
44
|
+
* @param {typeof fetch} [opts.fetch=globalThis.fetch] - e.g. `createFetch(...)` or a mock
|
|
45
|
+
* @param {HeadersInit} [opts.headers]
|
|
46
|
+
* @param {number} [opts.retries=5]
|
|
47
|
+
* @param {number} [opts.baseDelayMs=500]
|
|
48
|
+
* @param {number} [opts.maxDelayMs=10000]
|
|
49
|
+
* @param {(p: {loaded: number, total: number | null, percent: number | null}) => void} [opts.onProgress]
|
|
50
|
+
* @param {number} [opts.maxBytes] - throws BodyTooLargeError past this
|
|
51
|
+
* @param {AbortSignal} [opts.signal]
|
|
52
|
+
* @param {(err: any, attempt: number, delayMs: number, info: {resumeFrom: number}) => void} [opts.onRetry]
|
|
53
|
+
* @returns {Promise<Uint8Array>}
|
|
54
|
+
*/
|
|
55
|
+
async function downloadResumable(url, opts = {}) {
|
|
56
|
+
const {
|
|
57
|
+
fetch: fetchFn = globalThis.fetch,
|
|
58
|
+
headers,
|
|
59
|
+
retries = 5,
|
|
60
|
+
baseDelayMs = 500,
|
|
61
|
+
maxDelayMs = 10000,
|
|
62
|
+
onProgress,
|
|
63
|
+
maxBytes,
|
|
64
|
+
signal,
|
|
65
|
+
onRetry,
|
|
66
|
+
} = opts;
|
|
67
|
+
|
|
68
|
+
const chunks = [];
|
|
69
|
+
let loaded = 0;
|
|
70
|
+
let total = null;
|
|
71
|
+
let validator = null;
|
|
72
|
+
let attempt = 0; // consecutive attempts without progress
|
|
73
|
+
|
|
74
|
+
const report = () => {
|
|
75
|
+
if (onProgress) {
|
|
76
|
+
onProgress({ loaded, total, percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : null });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
for (;;) {
|
|
81
|
+
signal?.throwIfAborted?.();
|
|
82
|
+
const loadedBefore = loaded;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const reqHeaders = new Headers(headers || undefined);
|
|
86
|
+
// Range offsets count encoded bytes, so ask for an uncompressed body.
|
|
87
|
+
if (!reqHeaders.has("accept-encoding")) reqHeaders.set("accept-encoding", "identity");
|
|
88
|
+
if (loaded > 0) {
|
|
89
|
+
reqHeaders.set("range", `bytes=${loaded}-`);
|
|
90
|
+
if (validator) reqHeaders.set("if-range", validator);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const res = await fetchFn(url, { headers: reqHeaders, signal });
|
|
94
|
+
|
|
95
|
+
if (res.status === 416 && loaded > 0) {
|
|
96
|
+
// Our offset no longer fits the file — start over, without burning a retry.
|
|
97
|
+
await res.body?.cancel?.().catch(() => {});
|
|
98
|
+
chunks.length = 0;
|
|
99
|
+
loaded = 0;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
await res.body?.cancel?.().catch(() => {});
|
|
104
|
+
throw new HTTPError(res, { method: "GET", url });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const encoding = res.headers.get("content-encoding");
|
|
108
|
+
const encoded = Boolean(encoding) && encoding.trim().toLowerCase() !== "identity";
|
|
109
|
+
|
|
110
|
+
if (loaded > 0) {
|
|
111
|
+
const range = res.status === 206 ? parseContentRange(res.headers.get("content-range")) : null;
|
|
112
|
+
if (!range || range.start !== loaded || encoded) {
|
|
113
|
+
// Server ignored Range, the file changed (If-Range failed), or the
|
|
114
|
+
// bytes don't line up: discard and restart from the beginning.
|
|
115
|
+
chunks.length = 0;
|
|
116
|
+
loaded = 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (loaded === 0) {
|
|
121
|
+
const range = res.status === 206 ? parseContentRange(res.headers.get("content-range")) : null;
|
|
122
|
+
const length = Number(res.headers.get("content-length"));
|
|
123
|
+
total = encoded ? null : range ? range.total : Number.isFinite(length) && res.headers.has("content-length") ? length : null;
|
|
124
|
+
const etag = res.headers.get("etag");
|
|
125
|
+
validator = etag && !etag.startsWith("W/") ? etag : res.headers.get("last-modified");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (maxBytes !== undefined && total !== null && total > maxBytes) {
|
|
129
|
+
await res.body?.cancel?.().catch(() => {});
|
|
130
|
+
throw new BodyTooLargeError(maxBytes);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!res.body || typeof res.body.getReader !== "function") {
|
|
134
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
135
|
+
if (maxBytes !== undefined && buf.byteLength > maxBytes) throw new BodyTooLargeError(maxBytes);
|
|
136
|
+
chunks.push(buf);
|
|
137
|
+
loaded += buf.byteLength;
|
|
138
|
+
total = total ?? loaded;
|
|
139
|
+
report();
|
|
140
|
+
} else {
|
|
141
|
+
const reader = res.body.getReader();
|
|
142
|
+
const onAbort = () => reader.cancel(signal.reason).catch(() => {});
|
|
143
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
144
|
+
let finished = false;
|
|
145
|
+
try {
|
|
146
|
+
for (;;) {
|
|
147
|
+
const { done, value } = await reader.read();
|
|
148
|
+
signal?.throwIfAborted?.();
|
|
149
|
+
if (done) break;
|
|
150
|
+
chunks.push(value);
|
|
151
|
+
loaded += value.byteLength;
|
|
152
|
+
if (maxBytes !== undefined && loaded > maxBytes) throw new BodyTooLargeError(maxBytes);
|
|
153
|
+
report();
|
|
154
|
+
}
|
|
155
|
+
finished = true;
|
|
156
|
+
} finally {
|
|
157
|
+
signal?.removeEventListener("abort", onAbort);
|
|
158
|
+
if (!finished) reader.cancel().catch(() => {});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (total !== null && loaded < total) {
|
|
163
|
+
// Stream ended early without an error (some servers just close the socket).
|
|
164
|
+
throw Object.assign(new Error(`Incomplete download: got ${loaded} of ${total} bytes`), { code: "ECONNRESET" });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const out = new Uint8Array(loaded);
|
|
168
|
+
let offset = 0;
|
|
169
|
+
for (const chunk of chunks) {
|
|
170
|
+
out.set(chunk, offset);
|
|
171
|
+
offset += chunk.byteLength;
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (signal?.aborted) throw signal.reason ?? err;
|
|
176
|
+
if (!isRetryable(err)) throw err;
|
|
177
|
+
|
|
178
|
+
if (loaded > loadedBefore) attempt = 0; // it made progress — doesn't count against the budget
|
|
179
|
+
if (attempt >= retries) throw err;
|
|
180
|
+
|
|
181
|
+
let delayMs = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
182
|
+
if (err instanceof HTTPError) {
|
|
183
|
+
const headerMs = getWaitMsFromHeaders(err.response && err.response.headers);
|
|
184
|
+
if (headerMs !== null) delayMs = Math.min(headerMs, 60000);
|
|
185
|
+
}
|
|
186
|
+
attempt++;
|
|
187
|
+
if (onRetry) onRetry(err, attempt, delayMs, { resumeFrom: loaded });
|
|
188
|
+
await abortableSleep(delayMs, signal);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { downloadResumable };
|
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,19 @@ 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");
|
|
11
|
+
const { createRateLimiter } = require("./createRateLimiter");
|
|
12
|
+
const { createCircuitBreaker, CircuitOpenError } = require("./createCircuitBreaker");
|
|
13
|
+
const { createMemoryCache } = require("./createMemoryCache");
|
|
14
|
+
const { createMockFetch } = require("./createMockFetch");
|
|
15
|
+
const { downloadResumable } = require("./downloadResumable");
|
|
16
|
+
const { readLines, readNDJSON, readSSE } = require("./streams");
|
|
9
17
|
|
|
10
18
|
module.exports = {
|
|
11
19
|
withRetry,
|
|
12
20
|
RetryAbortedError,
|
|
21
|
+
StopRetry,
|
|
13
22
|
withTimeout,
|
|
14
23
|
TimeoutError,
|
|
15
24
|
normalizeError,
|
|
@@ -21,4 +30,16 @@ module.exports = {
|
|
|
21
30
|
withProgress,
|
|
22
31
|
BodyTooLargeError,
|
|
23
32
|
withDedupe,
|
|
33
|
+
createLimiter,
|
|
34
|
+
createFetch,
|
|
35
|
+
HTTPError,
|
|
36
|
+
createRateLimiter,
|
|
37
|
+
createCircuitBreaker,
|
|
38
|
+
CircuitOpenError,
|
|
39
|
+
createMemoryCache,
|
|
40
|
+
createMockFetch,
|
|
41
|
+
downloadResumable,
|
|
42
|
+
readLines,
|
|
43
|
+
readNDJSON,
|
|
44
|
+
readSSE,
|
|
24
45
|
};
|
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,18 @@ export const {
|
|
|
14
15
|
withProgress,
|
|
15
16
|
BodyTooLargeError,
|
|
16
17
|
withDedupe,
|
|
18
|
+
createLimiter,
|
|
19
|
+
createFetch,
|
|
20
|
+
HTTPError,
|
|
21
|
+
createRateLimiter,
|
|
22
|
+
createCircuitBreaker,
|
|
23
|
+
CircuitOpenError,
|
|
24
|
+
createMemoryCache,
|
|
25
|
+
createMockFetch,
|
|
26
|
+
downloadResumable,
|
|
27
|
+
readLines,
|
|
28
|
+
readNDJSON,
|
|
29
|
+
readSSE,
|
|
17
30
|
} = reqkit;
|
|
18
31
|
|
|
19
32
|
export default reqkit;
|
package/src/retryAfterAware.js
CHANGED
|
@@ -1,75 +1,23 @@
|
|
|
1
|
-
const {
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (onRetryError)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
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/streams.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
|
|
3
|
+
// Yields Uint8Array chunks from a fetch Response, a web ReadableStream, or any
|
|
4
|
+
// async iterable (e.g. a Node Readable). Cancels the underlying stream when
|
|
5
|
+
// the consumer stops early or the signal aborts, so the connection is freed.
|
|
6
|
+
async function* chunks(source, signal) {
|
|
7
|
+
let body = source;
|
|
8
|
+
if (body && typeof body.getReader !== "function" && "body" in body) body = body.body; // a Response
|
|
9
|
+
if (body === null || body === undefined) return;
|
|
10
|
+
signal?.throwIfAborted?.();
|
|
11
|
+
|
|
12
|
+
if (typeof body.getReader === "function") {
|
|
13
|
+
const reader = body.getReader();
|
|
14
|
+
const onAbort = () => reader.cancel(signal.reason).catch(() => {});
|
|
15
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
16
|
+
try {
|
|
17
|
+
for (;;) {
|
|
18
|
+
const { done, value } = await reader.read();
|
|
19
|
+
signal?.throwIfAborted?.(); // cancel() makes read() resolve as done — don't mistake that for the end
|
|
20
|
+
if (done) return;
|
|
21
|
+
yield value;
|
|
22
|
+
}
|
|
23
|
+
} finally {
|
|
24
|
+
signal?.removeEventListener("abort", onAbort);
|
|
25
|
+
reader.cancel().catch(() => {});
|
|
26
|
+
}
|
|
27
|
+
} else if (typeof body[Symbol.asyncIterator] === "function") {
|
|
28
|
+
for await (const chunk of body) {
|
|
29
|
+
signal?.throwIfAborted?.();
|
|
30
|
+
yield typeof chunk === "string" ? encoder.encode(chunk) : chunk;
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
throw new TypeError("reqkit streams: expected a Response, a ReadableStream or an async iterable");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Async-iterates the text lines of a streamed body. Handles \n, \r\n and \r,
|
|
39
|
+
* multi-byte characters split across chunks, and a leading BOM.
|
|
40
|
+
*
|
|
41
|
+
* ```js
|
|
42
|
+
* for await (const line of readLines(response)) console.log(line);
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
|
|
46
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
47
|
+
* @returns {AsyncGenerator<string>}
|
|
48
|
+
*/
|
|
49
|
+
async function* readLines(source, opts = {}) {
|
|
50
|
+
const decoder = new TextDecoder("utf-8");
|
|
51
|
+
let buffer = "";
|
|
52
|
+
let first = true;
|
|
53
|
+
|
|
54
|
+
for await (const chunk of chunks(source, opts.signal)) {
|
|
55
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
56
|
+
if (first && buffer.length > 0) {
|
|
57
|
+
buffer = buffer.replace(/^\uFEFF/, "");
|
|
58
|
+
first = false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let pos = 0;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const rest = buffer.slice(pos);
|
|
64
|
+
const i = rest.search(/\r\n|\n|\r/);
|
|
65
|
+
if (i === -1) break;
|
|
66
|
+
// A trailing "\r" might be the first half of "\r\n" — wait for more data.
|
|
67
|
+
if (rest[i] === "\r" && i === rest.length - 1) break;
|
|
68
|
+
yield rest.slice(0, i);
|
|
69
|
+
pos += i + (rest.startsWith("\r\n", i) ? 2 : 1);
|
|
70
|
+
}
|
|
71
|
+
buffer = buffer.slice(pos);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
buffer += decoder.decode();
|
|
75
|
+
if (buffer.endsWith("\r")) buffer = buffer.slice(0, -1);
|
|
76
|
+
if (buffer.length > 0) yield buffer;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Async-iterates parsed JSON values from a newline-delimited JSON stream
|
|
81
|
+
* (NDJSON / JSON Lines). Blank lines are skipped. A malformed line throws a
|
|
82
|
+
* SyntaxError naming the line, unless `skipInvalid` is true.
|
|
83
|
+
*
|
|
84
|
+
* @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
|
|
85
|
+
* @param {{ signal?: AbortSignal, skipInvalid?: boolean }} [opts]
|
|
86
|
+
*/
|
|
87
|
+
async function* readNDJSON(source, opts = {}) {
|
|
88
|
+
let lineNo = 0;
|
|
89
|
+
for await (const line of readLines(source, opts)) {
|
|
90
|
+
lineNo++;
|
|
91
|
+
if (line.trim() === "") continue;
|
|
92
|
+
try {
|
|
93
|
+
yield JSON.parse(line);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (opts.skipInvalid) continue;
|
|
96
|
+
throw new SyntaxError(`readNDJSON: invalid JSON on line ${lineNo}: ${err.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Async-iterates Server-Sent Events (text/event-stream) as
|
|
103
|
+
* `{ event, data, id, retry }`, following the spec: multi-line `data:` is
|
|
104
|
+
* joined with "\n", comments (`:`) are ignored, `event` defaults to
|
|
105
|
+
* "message", `id` carries over between events, and an event cut off by the end
|
|
106
|
+
* of the stream is discarded. Great for LLM token streams.
|
|
107
|
+
*
|
|
108
|
+
* ```js
|
|
109
|
+
* for await (const { data } of readSSE(response)) {
|
|
110
|
+
* if (data === "[DONE]") break;
|
|
111
|
+
* console.log(JSON.parse(data));
|
|
112
|
+
* }
|
|
113
|
+
* ```
|
|
114
|
+
*
|
|
115
|
+
* @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
|
|
116
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
117
|
+
*/
|
|
118
|
+
async function* readSSE(source, opts = {}) {
|
|
119
|
+
let event = "";
|
|
120
|
+
let data = [];
|
|
121
|
+
let retry;
|
|
122
|
+
let lastId = "";
|
|
123
|
+
|
|
124
|
+
for await (const line of readLines(source, opts)) {
|
|
125
|
+
if (line === "") {
|
|
126
|
+
if (data.length > 0) yield { event: event || "message", data: data.join("\n"), id: lastId, retry };
|
|
127
|
+
event = "";
|
|
128
|
+
data = [];
|
|
129
|
+
retry = undefined;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (line.startsWith(":")) continue;
|
|
133
|
+
|
|
134
|
+
const idx = line.indexOf(":");
|
|
135
|
+
const field = idx === -1 ? line : line.slice(0, idx);
|
|
136
|
+
let value = idx === -1 ? "" : line.slice(idx + 1);
|
|
137
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
138
|
+
|
|
139
|
+
if (field === "event") event = value;
|
|
140
|
+
else if (field === "data") data.push(value);
|
|
141
|
+
else if (field === "id") {
|
|
142
|
+
if (!value.includes("\0")) lastId = value;
|
|
143
|
+
} else if (field === "retry") {
|
|
144
|
+
if (/^\d+$/.test(value)) retry = Number(value);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { readLines, readNDJSON, readSSE };
|