@boy-offi9-inc/reqkit 0.3.0 → 0.6.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 +174 -1
- package/RECIPES.md +243 -0
- package/dist/reqkit.mjs +2507 -0
- package/package.json +25 -5
- package/src/HTTPError.js +14 -0
- package/src/_cache.js +55 -0
- package/src/_http.js +44 -13
- package/src/_sleep.js +20 -1
- package/src/createCircuitBreaker.js +124 -0
- package/src/createFetch.js +225 -45
- 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 +17 -0
- package/src/index.mjs +10 -0
- package/src/normalizeError.js +1 -1
- package/src/streamSSE.js +197 -0
- package/src/streams.js +149 -0
- package/types/index.d.ts +211 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boy-offi9-inc/reqkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Small, composable HTTP helpers — retry with backoff and Retry-After, timeouts, normalized errors,
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Small, composable HTTP helpers — retry with backoff and Retry-After, timeouts, normalized errors, rate limiter, circuit breaker, response cache, resumable downloads, SSE/NDJSON streams, mock fetch, 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": {
|
|
@@ -15,12 +15,19 @@
|
|
|
15
15
|
"default": "./src/index.js"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
|
+
"./browser": {
|
|
19
|
+
"types": "./types/index.d.mts",
|
|
20
|
+
"default": "./dist/reqkit.mjs"
|
|
21
|
+
},
|
|
18
22
|
"./package.json": "./package.json"
|
|
19
23
|
},
|
|
20
24
|
"sideEffects": false,
|
|
21
25
|
"scripts": {
|
|
26
|
+
"build": "node scripts/build-esm.js",
|
|
22
27
|
"test": "node --test",
|
|
23
|
-
"typecheck": "tsc -p tsconfig.json"
|
|
28
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
29
|
+
"smoke": "node smoke/run.mjs",
|
|
30
|
+
"prepublishOnly": "npm run build && npm test && npm run typecheck"
|
|
24
31
|
},
|
|
25
32
|
"keywords": [
|
|
26
33
|
"http",
|
|
@@ -38,7 +45,19 @@
|
|
|
38
45
|
"dedupe",
|
|
39
46
|
"http-client",
|
|
40
47
|
"fetch-wrapper",
|
|
41
|
-
"abort-signal"
|
|
48
|
+
"abort-signal",
|
|
49
|
+
"circuit-breaker",
|
|
50
|
+
"cache",
|
|
51
|
+
"sse",
|
|
52
|
+
"ndjson",
|
|
53
|
+
"streaming",
|
|
54
|
+
"resumable-download",
|
|
55
|
+
"mock",
|
|
56
|
+
"browser",
|
|
57
|
+
"deno",
|
|
58
|
+
"bun",
|
|
59
|
+
"cloudflare-workers",
|
|
60
|
+
"esm"
|
|
42
61
|
],
|
|
43
62
|
"author": "Boy Offi9",
|
|
44
63
|
"license": "MIT",
|
|
@@ -59,8 +78,9 @@
|
|
|
59
78
|
"files": [
|
|
60
79
|
"src",
|
|
61
80
|
"types",
|
|
81
|
+
"dist",
|
|
62
82
|
"README.md",
|
|
63
|
-
"
|
|
83
|
+
"RECIPES.md",
|
|
64
84
|
"LICENSE"
|
|
65
85
|
],
|
|
66
86
|
"devDependencies": {
|
package/src/HTTPError.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class HTTPError extends Error {
|
|
2
|
+
constructor(response, request) {
|
|
3
|
+
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
4
|
+
super(`Request failed with status ${response.status}${statusText}: ${request.method} ${request.url}`);
|
|
5
|
+
this.name = "HTTPError";
|
|
6
|
+
this.status = response.status;
|
|
7
|
+
this.statusText = response.statusText || "";
|
|
8
|
+
this.response = response;
|
|
9
|
+
this.url = request.url;
|
|
10
|
+
this.method = request.method;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = { HTTPError };
|
package/src/_cache.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Helpers for createFetch's response cache.
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
function cacheKey(method, url, headers) {
|
|
6
|
+
// Every request header is part of the key, so responses fetched with one
|
|
7
|
+
// user's Authorization header can never be served to another.
|
|
8
|
+
return `${method} ${url} ${JSON.stringify([...headers.entries()].sort())}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reads a response (via a clone — the original stays usable) into a plain,
|
|
13
|
+
* storable entry. Returns null when the response must not be cached.
|
|
14
|
+
*/
|
|
15
|
+
async function snapshotResponse(response, maxBodyBytes = DEFAULT_MAX_BODY_BYTES) {
|
|
16
|
+
if (response.status !== 200) return null;
|
|
17
|
+
|
|
18
|
+
const cacheControl = (response.headers.get("cache-control") || "").toLowerCase();
|
|
19
|
+
if (/\bno-store\b/.test(cacheControl)) return null;
|
|
20
|
+
if ((response.headers.get("vary") || "").trim() === "*") return null;
|
|
21
|
+
|
|
22
|
+
const declared = Number(response.headers.get("content-length"));
|
|
23
|
+
if (response.headers.has("content-length") && declared > maxBodyBytes) return null;
|
|
24
|
+
|
|
25
|
+
const copy = response.clone();
|
|
26
|
+
const buffer = await copy.arrayBuffer();
|
|
27
|
+
if (buffer.byteLength > maxBodyBytes) return null;
|
|
28
|
+
|
|
29
|
+
const headers = [];
|
|
30
|
+
response.headers.forEach((value, key) => {
|
|
31
|
+
// The body we stored is already decoded; replaying these would corrupt it.
|
|
32
|
+
if (key === "content-encoding" || key === "content-length" || key === "transfer-encoding") return;
|
|
33
|
+
headers.push([key, value]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
status: response.status,
|
|
38
|
+
statusText: response.statusText,
|
|
39
|
+
headers,
|
|
40
|
+
body: new Uint8Array(buffer),
|
|
41
|
+
storedAt: Date.now(),
|
|
42
|
+
etag: response.headers.get("etag"),
|
|
43
|
+
lastModified: response.headers.get("last-modified"),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function entryToResponse(entry) {
|
|
48
|
+
return new Response(entry.body.slice(), {
|
|
49
|
+
status: entry.status,
|
|
50
|
+
statusText: entry.statusText,
|
|
51
|
+
headers: entry.headers,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { cacheKey, snapshotResponse, entryToResponse, DEFAULT_MAX_BODY_BYTES };
|
package/src/_http.js
CHANGED
|
@@ -20,9 +20,30 @@ function parseRetryAfter(headerValue) {
|
|
|
20
20
|
return null;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// Reset headers come in two flavors: a delta in seconds ("30", the IETF
|
|
24
|
+
// RateLimit-Reset draft, Discord's *-Reset-After) or an absolute epoch
|
|
25
|
+
// (GitHub/Twitter style, seconds or ms). Anything under ~31 years in seconds
|
|
26
|
+
// can't be a real epoch, so treat it as a delta.
|
|
27
|
+
function parseResetMs(value) {
|
|
28
|
+
if (value === null || value === undefined || String(value).trim() === "") return null;
|
|
29
|
+
const n = Number(value);
|
|
30
|
+
if (Number.isNaN(n) || n < 0) return null;
|
|
31
|
+
if (n < 1e9) return n * 1000;
|
|
32
|
+
const epochMs = n > 4102444800 ? n : n * 1000;
|
|
33
|
+
return Math.max(0, epochMs - Date.now());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readHeader(headers, ...names) {
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
const v = headers.get(name);
|
|
39
|
+
if (v !== null && v !== undefined && v !== "") return v;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
/**
|
|
24
45
|
* Reads a wait time from common rate-limit headers, trying Retry-After first,
|
|
25
|
-
* then falling back to X-RateLimit-Reset
|
|
46
|
+
* then falling back to X-RateLimit-Reset(-After) if present.
|
|
26
47
|
*/
|
|
27
48
|
function getWaitMsFromHeaders(headers) {
|
|
28
49
|
if (!headers || typeof headers.get !== "function") return null;
|
|
@@ -30,18 +51,28 @@ function getWaitMsFromHeaders(headers) {
|
|
|
30
51
|
const retryAfter = parseRetryAfter(headers.get("retry-after"));
|
|
31
52
|
if (retryAfter !== null) return retryAfter;
|
|
32
53
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
}
|
|
54
|
+
return parseResetMs(
|
|
55
|
+
readHeader(headers, "x-ratelimit-reset-after", "x-ratelimit-reset", "x-rate-limit-reset", "ratelimit-reset")
|
|
56
|
+
);
|
|
57
|
+
}
|
|
43
58
|
|
|
44
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Everything useful a response says about rate limits:
|
|
61
|
+
* { remaining: number|null, resetMs: number|null, retryAfterMs: number|null }
|
|
62
|
+
* Understands X-RateLimit-*, X-Rate-Limit-*, RateLimit-* and Retry-After.
|
|
63
|
+
*/
|
|
64
|
+
function getRateLimitInfo(headers) {
|
|
65
|
+
const info = { remaining: null, resetMs: null, retryAfterMs: null };
|
|
66
|
+
if (!headers || typeof headers.get !== "function") return info;
|
|
67
|
+
|
|
68
|
+
const remaining = readHeader(headers, "x-ratelimit-remaining", "x-rate-limit-remaining", "ratelimit-remaining");
|
|
69
|
+
if (remaining !== null && !Number.isNaN(Number(remaining))) info.remaining = Number(remaining);
|
|
70
|
+
|
|
71
|
+
info.resetMs = parseResetMs(
|
|
72
|
+
readHeader(headers, "x-ratelimit-reset-after", "x-ratelimit-reset", "x-rate-limit-reset", "ratelimit-reset")
|
|
73
|
+
);
|
|
74
|
+
info.retryAfterMs = parseRetryAfter(headers.get("retry-after"));
|
|
75
|
+
return info;
|
|
45
76
|
}
|
|
46
77
|
|
|
47
78
|
async function discardBody(response) {
|
|
@@ -56,4 +87,4 @@ async function discardBody(response) {
|
|
|
56
87
|
}
|
|
57
88
|
}
|
|
58
89
|
|
|
59
|
-
module.exports = { parseRetryAfter, getWaitMsFromHeaders, discardBody };
|
|
90
|
+
module.exports = { parseRetryAfter, parseResetMs, getWaitMsFromHeaders, getRateLimitInfo, discardBody };
|
package/src/_sleep.js
CHANGED
|
@@ -29,4 +29,23 @@ function sleep(ms, signal, cause) {
|
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/** Like sleep(), but rejects with the signal's own reason (an AbortError by default). */
|
|
33
|
+
function abortableSleep(ms, signal) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
if (signal?.aborted) {
|
|
36
|
+
reject(signal.reason);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const onAbort = () => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
reject(signal.reason);
|
|
42
|
+
};
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
signal?.removeEventListener("abort", onAbort);
|
|
45
|
+
resolve();
|
|
46
|
+
}, ms);
|
|
47
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { RetryAbortedError, sleep, abortableSleep };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
class CircuitOpenError extends Error {
|
|
2
|
+
constructor(retryAfterMs) {
|
|
3
|
+
super("Circuit is open — failing fast instead of calling a service that keeps failing");
|
|
4
|
+
this.name = "CircuitOpenError";
|
|
5
|
+
// Deliberately not an E-code: this is *not* a network error and must not be retried as one.
|
|
6
|
+
this.code = "CIRCUIT_OPEN";
|
|
7
|
+
this.retryAfterMs = retryAfterMs;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function defaultIsFailure(valueOrError, isError) {
|
|
12
|
+
if (isError) return !(valueOrError && valueOrError.name === "AbortError");
|
|
13
|
+
return Boolean(valueOrError) && typeof valueOrError.status === "number" && valueOrError.status >= 500;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Circuit breaker: after `failureThreshold` consecutive failures it "opens"
|
|
18
|
+
* and fails fast with CircuitOpenError for `resetTimeoutMs`, so a dying
|
|
19
|
+
* service isn't hammered (and your callers don't wait on it). Then it goes
|
|
20
|
+
* "half-open" and lets `halfOpenMax` probe through: success closes the
|
|
21
|
+
* circuit, failure re-opens it.
|
|
22
|
+
*
|
|
23
|
+
* By default a thrown error (except AbortError) or a result with status >= 500
|
|
24
|
+
* counts as a failure; override with `isFailure(valueOrError, isError)`.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} [opts]
|
|
27
|
+
* @param {number} [opts.failureThreshold=5]
|
|
28
|
+
* @param {number} [opts.resetTimeoutMs=30000]
|
|
29
|
+
* @param {number} [opts.halfOpenMax=1]
|
|
30
|
+
* @param {(valueOrError: any, isError: boolean) => boolean} [opts.isFailure]
|
|
31
|
+
* @param {(state: string, prev: string) => void} [opts.onStateChange]
|
|
32
|
+
*/
|
|
33
|
+
function createCircuitBreaker(opts = {}) {
|
|
34
|
+
const {
|
|
35
|
+
failureThreshold = 5,
|
|
36
|
+
resetTimeoutMs = 30000,
|
|
37
|
+
halfOpenMax = 1,
|
|
38
|
+
isFailure = defaultIsFailure,
|
|
39
|
+
onStateChange,
|
|
40
|
+
} = opts;
|
|
41
|
+
|
|
42
|
+
let state = "closed";
|
|
43
|
+
let failures = 0;
|
|
44
|
+
let openedAt = 0;
|
|
45
|
+
let probes = 0;
|
|
46
|
+
|
|
47
|
+
function setState(next) {
|
|
48
|
+
if (next === state) return;
|
|
49
|
+
const prev = state;
|
|
50
|
+
state = next;
|
|
51
|
+
if (onStateChange) onStateChange(next, prev);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function open() {
|
|
55
|
+
openedAt = Date.now();
|
|
56
|
+
setState("open");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function current() {
|
|
60
|
+
if (state === "open" && Date.now() - openedAt >= resetTimeoutMs) {
|
|
61
|
+
probes = 0;
|
|
62
|
+
setState("half-open");
|
|
63
|
+
}
|
|
64
|
+
return state;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function recordFailure() {
|
|
68
|
+
if (state === "half-open") {
|
|
69
|
+
open();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
failures++;
|
|
73
|
+
if (failures >= failureThreshold) open();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function recordSuccess() {
|
|
77
|
+
failures = 0;
|
|
78
|
+
if (state === "half-open") setState("closed");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function run(fn) {
|
|
82
|
+
const s = current();
|
|
83
|
+
if (s === "open") {
|
|
84
|
+
throw new CircuitOpenError(Math.max(0, resetTimeoutMs - (Date.now() - openedAt)));
|
|
85
|
+
}
|
|
86
|
+
let probing = false;
|
|
87
|
+
if (s === "half-open") {
|
|
88
|
+
if (probes >= halfOpenMax) throw new CircuitOpenError(0);
|
|
89
|
+
probes++;
|
|
90
|
+
probing = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let result;
|
|
94
|
+
try {
|
|
95
|
+
result = await fn();
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (isFailure(err, true)) recordFailure();
|
|
98
|
+
else if (probing) probes--; // e.g. an abort — free the probe slot
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (isFailure(result, false)) recordFailure();
|
|
103
|
+
else recordSuccess();
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
run,
|
|
109
|
+
get state() {
|
|
110
|
+
return current();
|
|
111
|
+
},
|
|
112
|
+
get failures() {
|
|
113
|
+
return failures;
|
|
114
|
+
},
|
|
115
|
+
/** Force the circuit closed and forget the failure count. */
|
|
116
|
+
reset() {
|
|
117
|
+
failures = 0;
|
|
118
|
+
probes = 0;
|
|
119
|
+
setState("closed");
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { createCircuitBreaker, CircuitOpenError };
|