@boy-offi9-inc/reqkit 0.7.1 → 0.9.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 +134 -66
- package/dist/reqkit.mjs +2429 -2020
- package/package.json +11 -5
- package/src/{createFetch.js → http/createFetch.js} +203 -26
- package/src/http/latency.js +41 -0
- package/src/index.js +20 -18
- package/src/index.mjs +1 -0
- package/src/{paginate.js → pagination/paginate.js} +2 -2
- package/src/resilience/hedge.js +168 -0
- package/src/{createRateLimiter.js → resilience/rateLimiter.js} +7 -4
- package/src/{withRetry.js → resilience/retry.js} +2 -2
- package/src/{retryAfterAware.js → resilience/retryAfterAware.js} +3 -3
- package/src/{downloadResumable.js → streams/download.js} +5 -5
- package/src/{streamSSE.js → streams/streamSSE.js} +5 -5
- package/src/{createMockFetch.js → testing/createMockFetch.js} +1 -1
- package/types/errors.d.ts +24 -0
- package/types/http.d.ts +209 -0
- package/types/index.d.ts +7 -538
- package/types/pagination.d.ts +62 -0
- package/types/resilience.d.ts +194 -0
- package/types/streams.d.ts +105 -0
- package/types/testing.d.ts +44 -0
- /package/{RECIPES.md → docs/recipes.md} +0 -0
- /package/src/{HTTPError.js → errors/HTTPError.js} +0 -0
- /package/src/{normalizeError.js → errors/normalizeError.js} +0 -0
- /package/src/{buildQuery.js → http/buildQuery.js} +0 -0
- /package/src/{_cache.js → http/cache/entry.js} +0 -0
- /package/src/{createMemoryCache.js → http/cache/memoryCache.js} +0 -0
- /package/src/{parseJsonSafe.js → http/parseJsonSafe.js} +0 -0
- /package/src/{_http.js → internal/http.js} +0 -0
- /package/src/{_sleep.js → internal/sleep.js} +0 -0
- /package/src/{createCircuitBreaker.js → resilience/circuitBreaker.js} +0 -0
- /package/src/{withDedupe.js → resilience/dedupe.js} +0 -0
- /package/src/{createLimiter.js → resilience/limiter.js} +0 -0
- /package/src/{withTimeout.js → resilience/timeout.js} +0 -0
- /package/src/{withProgress.js → streams/progress.js} +0 -0
- /package/src/{streams.js → streams/readers.js} +0 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const { discardBody } = require("../internal/http");
|
|
2
|
+
|
|
3
|
+
function defaultIsFailure(result) {
|
|
4
|
+
return Boolean(result) && typeof result.status === "number" && result.status >= 500;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function lostReason() {
|
|
8
|
+
const reason = new Error("Request cancelled: a faster hedged request won");
|
|
9
|
+
reason.name = "AbortError";
|
|
10
|
+
return reason;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hedged requests: start `fn`; if it hasn't answered within `delayMs`, start
|
|
15
|
+
* a backup (a "hedge"); whichever succeeds first wins and the others are
|
|
16
|
+
* aborted. It trims the slow tail of latency — the occasional request that
|
|
17
|
+
* takes 10× longer than the rest — at the cost of a little extra load.
|
|
18
|
+
*
|
|
19
|
+
* ```js
|
|
20
|
+
* const res = await withHedge((signal) => fetch(url, { signal }), { delayMs: 200 });
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Only hedge requests that are safe to run twice (GETs, idempotent calls).
|
|
24
|
+
*
|
|
25
|
+
* Details:
|
|
26
|
+
* - `fn(signal, index)` — `index` is 0 for the original and 1.. for backups.
|
|
27
|
+
* Abort-aware code (like `fetch`) is actually cancelled when it loses.
|
|
28
|
+
* - An attempt that throws, or returns a failed result (status >= 500 by
|
|
29
|
+
* default), launches the next backup immediately instead of waiting.
|
|
30
|
+
* - If every attempt fails: resolves with the last failed *result* if there was
|
|
31
|
+
* one (a 503 `Response` isn't thrown), otherwise rejects with the last error.
|
|
32
|
+
* - The bodies of responses that lose are cancelled so connections are freed.
|
|
33
|
+
*
|
|
34
|
+
* @template T
|
|
35
|
+
* @param {(signal: AbortSignal, index: number) => Promise<T>} fn
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {number | ((index: number) => number)} opts.delayMs - wait before launching backup #index (1-based)
|
|
38
|
+
* @param {number} [opts.maxHedges=1] - how many backups at most
|
|
39
|
+
* @param {(result: T) => boolean} [opts.isFailure] - results that count as failed. Default: `result.status >= 500`
|
|
40
|
+
* @param {boolean} [opts.hedgeOnError=true] - launch the next backup right away when an attempt throws
|
|
41
|
+
* @param {(info: { index: number, afterMs: number }) => void} [opts.onHedge] - a backup was launched
|
|
42
|
+
* @param {(index: number) => void} [opts.onWinner] - which attempt produced the result (0 = the original)
|
|
43
|
+
* @param {AbortSignal} [opts.signal] - cancels everything; rejects with the signal's reason
|
|
44
|
+
* @returns {Promise<T>}
|
|
45
|
+
*/
|
|
46
|
+
function withHedge(fn, opts = {}) {
|
|
47
|
+
const { delayMs, maxHedges = 1, isFailure = defaultIsFailure, hedgeOnError = true, onHedge, onWinner, signal } = opts;
|
|
48
|
+
|
|
49
|
+
const delayFor = typeof delayMs === "function" ? delayMs : () => delayMs;
|
|
50
|
+
if (typeof delayMs !== "function" && !(typeof delayMs === "number" && delayMs >= 0 && Number.isFinite(delayMs))) {
|
|
51
|
+
return Promise.reject(new TypeError("withHedge: `delayMs` must be a non-negative number or a function returning one"));
|
|
52
|
+
}
|
|
53
|
+
if (!(Number.isInteger(maxHedges) && maxHedges >= 0)) {
|
|
54
|
+
return Promise.reject(new TypeError("withHedge: `maxHedges` must be an integer >= 0"));
|
|
55
|
+
}
|
|
56
|
+
if (signal && signal.aborted) return Promise.reject(signal.reason);
|
|
57
|
+
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const startedAt = Date.now();
|
|
60
|
+
const attempts = []; // { controller, done }
|
|
61
|
+
let launched = 0;
|
|
62
|
+
let pending = 0;
|
|
63
|
+
let settled = false;
|
|
64
|
+
let timer = null;
|
|
65
|
+
let lastError;
|
|
66
|
+
let hasError = false;
|
|
67
|
+
let failedResult;
|
|
68
|
+
let hasFailedResult = false;
|
|
69
|
+
|
|
70
|
+
const onAbort = () => finish(reject, signal.reason, -1);
|
|
71
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
72
|
+
|
|
73
|
+
function finish(settle, value, winnerIndex) {
|
|
74
|
+
if (settled) return;
|
|
75
|
+
settled = true;
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
78
|
+
attempts.forEach((attempt, index) => {
|
|
79
|
+
if (index !== winnerIndex && !attempt.done) attempt.controller.abort(lostReason());
|
|
80
|
+
});
|
|
81
|
+
if (hasFailedResult && failedResult !== value) discardBody(failedResult);
|
|
82
|
+
if (winnerIndex >= 0 && onWinner) {
|
|
83
|
+
try {
|
|
84
|
+
onWinner(winnerIndex);
|
|
85
|
+
} catch {
|
|
86
|
+
// ignore
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
settle(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function scheduleBackup() {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
if (launched > maxHedges) return; // `launched` counts the original too
|
|
95
|
+
timer = setTimeout(() => launch(), Math.max(0, Number(delayFor(launched)) || 0));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function launch() {
|
|
99
|
+
if (settled) return;
|
|
100
|
+
const index = launched++;
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const attempt = { controller, done: false };
|
|
103
|
+
attempts[index] = attempt;
|
|
104
|
+
pending++;
|
|
105
|
+
|
|
106
|
+
if (index > 0 && onHedge) {
|
|
107
|
+
try {
|
|
108
|
+
onHedge({ index, afterMs: Date.now() - startedAt });
|
|
109
|
+
} catch {
|
|
110
|
+
// ignore
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let promise;
|
|
115
|
+
try {
|
|
116
|
+
promise = Promise.resolve(fn(controller.signal, index));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
promise = Promise.reject(error);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
promise.then(
|
|
122
|
+
(result) => {
|
|
123
|
+
attempt.done = true;
|
|
124
|
+
pending--;
|
|
125
|
+
if (settled) return discardBody(result); // too late — it lost
|
|
126
|
+
if (isFailure(result)) {
|
|
127
|
+
if (hasFailedResult) discardBody(failedResult);
|
|
128
|
+
failedResult = result;
|
|
129
|
+
hasFailedResult = true;
|
|
130
|
+
return next();
|
|
131
|
+
}
|
|
132
|
+
finish(resolve, result, index);
|
|
133
|
+
},
|
|
134
|
+
(error) => {
|
|
135
|
+
attempt.done = true;
|
|
136
|
+
pending--;
|
|
137
|
+
if (settled) return;
|
|
138
|
+
lastError = error;
|
|
139
|
+
hasError = true;
|
|
140
|
+
if (hedgeOnError) next();
|
|
141
|
+
else if (pending === 0 && launched > maxHedges) giveUp();
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
scheduleBackup();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// An attempt failed: start the next backup now, or give up if there are none left.
|
|
149
|
+
function next() {
|
|
150
|
+
if (settled) return;
|
|
151
|
+
if (launched <= maxHedges) {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
launch();
|
|
154
|
+
} else if (pending === 0) {
|
|
155
|
+
giveUp();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function giveUp() {
|
|
160
|
+
if (hasFailedResult) finish(resolve, failedResult, -1);
|
|
161
|
+
else finish(reject, hasError ? lastError : new Error("withHedge: no attempt succeeded"), -1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
launch();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { withHedge };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const { abortableSleep } = require("
|
|
2
|
-
const { getRateLimitInfo } = require("
|
|
1
|
+
const { abortableSleep } = require("../internal/sleep");
|
|
2
|
+
const { getRateLimitInfo } = require("../internal/http");
|
|
3
3
|
|
|
4
4
|
function abortable(promise, signal) {
|
|
5
5
|
if (!signal) return promise;
|
|
@@ -50,6 +50,7 @@ function createRateLimiter(opts = {}) {
|
|
|
50
50
|
let tail = Promise.resolve();
|
|
51
51
|
|
|
52
52
|
async function take(signal) {
|
|
53
|
+
let waitedMs = 0;
|
|
53
54
|
for (;;) {
|
|
54
55
|
signal?.throwIfAborted?.();
|
|
55
56
|
const now = Date.now();
|
|
@@ -61,13 +62,15 @@ function createRateLimiter(opts = {}) {
|
|
|
61
62
|
|
|
62
63
|
if (waitMs <= 0) {
|
|
63
64
|
stamps.push(now);
|
|
64
|
-
return;
|
|
65
|
+
return waitedMs;
|
|
65
66
|
}
|
|
67
|
+
const waitStartedAt = Date.now();
|
|
66
68
|
await abortableSleep(waitMs, signal);
|
|
69
|
+
waitedMs += Date.now() - waitStartedAt;
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
/** Resolves
|
|
73
|
+
/** Resolves with the time spent waiting. Rejects if aborted while waiting. */
|
|
71
74
|
function acquire({ signal } = {}) {
|
|
72
75
|
const turn = tail.then(() => take(signal));
|
|
73
76
|
tail = turn.catch(() => {});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const { RetryAbortedError, sleep } = require("
|
|
2
|
-
const { getWaitMsFromHeaders, discardBody } = require("
|
|
1
|
+
const { RetryAbortedError, sleep } = require("../internal/sleep");
|
|
2
|
+
const { getWaitMsFromHeaders, discardBody } = require("../internal/http");
|
|
3
3
|
|
|
4
4
|
function defaultShouldRetry(err) {
|
|
5
5
|
// Retry thrown errors except deliberate aborts. Narrow it with `shouldRetry`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const { withRetry } = require("./
|
|
2
|
-
const { normalizeError } = require("
|
|
3
|
-
const { parseRetryAfter, getWaitMsFromHeaders } = require("
|
|
1
|
+
const { withRetry } = require("./retry");
|
|
2
|
+
const { normalizeError } = require("../errors/normalizeError");
|
|
3
|
+
const { parseRetryAfter, getWaitMsFromHeaders } = require("../internal/http");
|
|
4
4
|
|
|
5
5
|
function defaultShouldRetryError(err) {
|
|
6
6
|
const normalized = normalizeError(err);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
const { HTTPError } = require("
|
|
2
|
-
const { BodyTooLargeError } = require("./
|
|
3
|
-
const { normalizeError } = require("
|
|
4
|
-
const { getWaitMsFromHeaders } = require("
|
|
5
|
-
const { abortableSleep } = require("
|
|
1
|
+
const { HTTPError } = require("../errors/HTTPError");
|
|
2
|
+
const { BodyTooLargeError } = require("./progress");
|
|
3
|
+
const { normalizeError } = require("../errors/normalizeError");
|
|
4
|
+
const { getWaitMsFromHeaders } = require("../internal/http");
|
|
5
|
+
const { abortableSleep } = require("../internal/sleep");
|
|
6
6
|
|
|
7
7
|
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
8
8
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
const { readSSE } = require("./
|
|
2
|
-
const { HTTPError } = require("
|
|
3
|
-
const { normalizeError } = require("
|
|
4
|
-
const { getWaitMsFromHeaders, discardBody } = require("
|
|
5
|
-
const { abortableSleep } = require("
|
|
1
|
+
const { readSSE } = require("./readers");
|
|
2
|
+
const { HTTPError } = require("../errors/HTTPError");
|
|
3
|
+
const { normalizeError } = require("../errors/normalizeError");
|
|
4
|
+
const { getWaitMsFromHeaders, discardBody } = require("../internal/http");
|
|
5
|
+
const { abortableSleep } = require("../internal/sleep");
|
|
6
6
|
|
|
7
7
|
function isRetryableStatus(status) {
|
|
8
8
|
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Error types and classification — src/errors/
|
|
2
|
+
|
|
3
|
+
export interface NormalizedError {
|
|
4
|
+
message: string;
|
|
5
|
+
status: number | null;
|
|
6
|
+
code: string | null;
|
|
7
|
+
isNetworkError: boolean;
|
|
8
|
+
isTimeout: boolean;
|
|
9
|
+
/** True when the request was cancelled on purpose (AbortController, axios cancel). */
|
|
10
|
+
isAborted: boolean;
|
|
11
|
+
cause: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeError(err: unknown): NormalizedError;
|
|
15
|
+
|
|
16
|
+
export class HTTPError extends Error {
|
|
17
|
+
name: "HTTPError";
|
|
18
|
+
status: number;
|
|
19
|
+
statusText: string;
|
|
20
|
+
response: Response;
|
|
21
|
+
url: string;
|
|
22
|
+
method: string;
|
|
23
|
+
constructor(response: Response, request: { method: string; url: string });
|
|
24
|
+
}
|
package/types/http.d.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// createFetch and the request/response helpers — src/http/
|
|
2
|
+
import type { CircuitBreaker, CircuitBreakerOptions, CircuitState, RateLimiter, RateLimiterOptions, RetryOptions } from "./resilience";
|
|
3
|
+
|
|
4
|
+
export interface JsonSafeResult<T = unknown> {
|
|
5
|
+
data: T | null;
|
|
6
|
+
error: Error | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseJsonSafe<T = unknown>(input: Response | string): Promise<JsonSafeResult<T>>;
|
|
10
|
+
|
|
11
|
+
export function buildQuery(params: Record<string, unknown> | null | undefined): string;
|
|
12
|
+
|
|
13
|
+
export interface CreateFetchRetryOptions {
|
|
14
|
+
/** Default 2. */
|
|
15
|
+
retries?: number;
|
|
16
|
+
/** Default [408, 425, 429, 500, 502, 503, 504]. */
|
|
17
|
+
statusCodes?: number[];
|
|
18
|
+
/** Methods that may be retried. Default GET, HEAD, OPTIONS, PUT, DELETE. */
|
|
19
|
+
methods?: string[];
|
|
20
|
+
baseDelayMs?: number;
|
|
21
|
+
maxDelayMs?: number;
|
|
22
|
+
factor?: number;
|
|
23
|
+
jitter?: number | "full";
|
|
24
|
+
maxRetryTime?: number;
|
|
25
|
+
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
|
26
|
+
calculateDelay?: RetryOptions["calculateDelay"];
|
|
27
|
+
onRetry?: RetryOptions["onRetry"];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FetchHookContext {
|
|
31
|
+
url: string;
|
|
32
|
+
init: RequestInit & { headers: Headers };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CreateFetchDefaults {
|
|
36
|
+
baseUrl?: string;
|
|
37
|
+
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
38
|
+
/** Per attempt, in ms. false disables. Default 30000. */
|
|
39
|
+
timeout?: number | false;
|
|
40
|
+
/** false disables retrying. */
|
|
41
|
+
retry?: false | CreateFetchRetryOptions;
|
|
42
|
+
/**
|
|
43
|
+
* `true` only learns from X-RateLimit-* / Retry-After headers; `{ requests, perMs }`
|
|
44
|
+
* also enforces a budget; or share a createRateLimiter() instance.
|
|
45
|
+
*/
|
|
46
|
+
rateLimit?: boolean | RateLimiterOptions | RateLimiter;
|
|
47
|
+
/** Options for a per-origin breaker, a shared breaker instance, or `(url) => breaker`. */
|
|
48
|
+
circuitBreaker?: boolean | CircuitBreakerOptions | CircuitBreaker | ((url: string) => CircuitBreaker);
|
|
49
|
+
/**
|
|
50
|
+
* Extra origins ("https://cdn.example.com") that may receive the default credentials
|
|
51
|
+
* (`auth` and default Authorization/Cookie/X-API-Key headers), besides `baseUrl`'s.
|
|
52
|
+
* Absolute URLs to any other origin don't get them. With neither `baseUrl` nor
|
|
53
|
+
* `trustedOrigins` there's nothing to compare against and credentials go everywhere.
|
|
54
|
+
*/
|
|
55
|
+
trustedOrigins?: string[];
|
|
56
|
+
/**
|
|
57
|
+
* Send a backup request when an attempt is slow and take whichever answers first
|
|
58
|
+
* (idempotent methods, or requests with an Idempotency-Key). `true` = delayMs "auto".
|
|
59
|
+
*/
|
|
60
|
+
hedge?: boolean | HedgeConfig;
|
|
61
|
+
/** Attach a token and, on a 401, refresh it once (shared by all concurrent requests) and replay. */
|
|
62
|
+
auth?: AuthOptions;
|
|
63
|
+
/** In-memory GET cache with TTL, stale-while-revalidate and ETag/Last-Modified revalidation. `true` = 60s TTL. */
|
|
64
|
+
cache?: boolean | CacheOptions;
|
|
65
|
+
/** Merge identical concurrent GET/HEAD calls. Default false. */
|
|
66
|
+
dedupe?: boolean;
|
|
67
|
+
/** Throw HTTPError on non-2xx. Default true. */
|
|
68
|
+
throwHttpErrors?: boolean;
|
|
69
|
+
fetch?: typeof fetch;
|
|
70
|
+
onRequest?: (ctx: FetchHookContext) => void | Promise<void>;
|
|
71
|
+
onResponse?: (ctx: FetchHookContext & { response: Response }) => void | Promise<void>;
|
|
72
|
+
/** One callback for everything that happens. Errors thrown by the handler are ignored. */
|
|
73
|
+
onEvent?: (event: FetchEvent) => void | Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ReqkitRequestInit extends Omit<RequestInit, "headers" | "cache"> {
|
|
77
|
+
headers?: HeadersInit;
|
|
78
|
+
query?: Record<string, unknown>;
|
|
79
|
+
/** Serialized as the body with a JSON content-type. */
|
|
80
|
+
json?: unknown;
|
|
81
|
+
timeout?: number | false;
|
|
82
|
+
retry?: false | CreateFetchRetryOptions;
|
|
83
|
+
throwHttpErrors?: boolean;
|
|
84
|
+
/** `false` disables hedging for this call; an object overrides the defaults. */
|
|
85
|
+
hedge?: boolean | HedgeConfig;
|
|
86
|
+
/** `false`/"no-store" bypass the cache; "reload" skips the read but refreshes the entry. */
|
|
87
|
+
cache?: false | "no-store" | "reload";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ReqkitFetch {
|
|
91
|
+
(input: string, init?: ReqkitRequestInit): Promise<Response>;
|
|
92
|
+
/** The `baseUrl` this instance was created with. */
|
|
93
|
+
readonly baseUrl: string | undefined;
|
|
94
|
+
/** Resolves to the parsed JSON body (null for an empty body). */
|
|
95
|
+
json<T = unknown>(input: string, init?: ReqkitRequestInit): Promise<T>;
|
|
96
|
+
/** New instance with these defaults merged over the current ones. */
|
|
97
|
+
extend(overrides?: CreateFetchDefaults): ReqkitFetch;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createFetch(defaults?: CreateFetchDefaults): ReqkitFetch;
|
|
101
|
+
|
|
102
|
+
export interface CacheStore {
|
|
103
|
+
get(key: string): any | Promise<any>;
|
|
104
|
+
set(key: string, value: any): void | Promise<void>;
|
|
105
|
+
delete(key: string): any;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface MemoryCache extends CacheStore {
|
|
109
|
+
clear(): void;
|
|
110
|
+
readonly size: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createMemoryCache(opts?: { maxEntries?: number }): MemoryCache;
|
|
114
|
+
|
|
115
|
+
export interface HedgeConfig {
|
|
116
|
+
/** Milliseconds before a backup is sent, or "auto" (default) to use your own recent latency. */
|
|
117
|
+
delayMs?: number | "auto";
|
|
118
|
+
/** Backups per attempt. Default 1. */
|
|
119
|
+
maxHedges?: number;
|
|
120
|
+
/** Methods that may be hedged. Default: the idempotent ones (GET, HEAD, OPTIONS, PUT, DELETE). */
|
|
121
|
+
methods?: string[];
|
|
122
|
+
/** For "auto": which percentile of recent latency to wait for. Default 0.95. */
|
|
123
|
+
percentile?: number;
|
|
124
|
+
/** For "auto": never hedge sooner than this. Default 20. */
|
|
125
|
+
minDelayMs?: number;
|
|
126
|
+
/** For "auto": the delay used until `minSamples` responses were seen. Default 300. */
|
|
127
|
+
fallbackDelayMs?: number;
|
|
128
|
+
/** For "auto": samples needed before trusting the percentile. Default 10. */
|
|
129
|
+
minSamples?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface CacheEvent {
|
|
133
|
+
type: "hit" | "stale" | "revalidated" | "miss" | "store" | "stale-if-error";
|
|
134
|
+
url: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface CacheOptions {
|
|
138
|
+
/** How long an entry is served without asking the server, in ms. Default 60000. */
|
|
139
|
+
ttl?: number;
|
|
140
|
+
/** After the TTL, keep serving the stale copy for this long while refreshing in the background. Default 0. */
|
|
141
|
+
staleWhileRevalidate?: number;
|
|
142
|
+
/**
|
|
143
|
+
* How long past the TTL a cached copy may still be served when the origin is failing
|
|
144
|
+
* (network error, timeout, open circuit, or a 5xx). 4xx responses and aborts are never masked. Default 0 (off).
|
|
145
|
+
*/
|
|
146
|
+
staleIfError?: number;
|
|
147
|
+
/** For the built-in memory store. Default 100. */
|
|
148
|
+
maxEntries?: number;
|
|
149
|
+
/** Responses larger than this aren't cached. Default 1 MiB. */
|
|
150
|
+
maxBodyBytes?: number;
|
|
151
|
+
/** Bring your own store (Redis, IndexedDB, a Map...). */
|
|
152
|
+
store?: CacheStore;
|
|
153
|
+
onEvent?: (event: CacheEvent) => void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface AuthOptions {
|
|
157
|
+
/** The current token (sync or async). Falsy = send no auth header. */
|
|
158
|
+
getToken(): string | null | undefined | Promise<string | null | undefined>;
|
|
159
|
+
/**
|
|
160
|
+
* Called ONCE when requests are rejected (401 by default), no matter how many
|
|
161
|
+
* are waiting. Return the new token, or update your store and return nothing
|
|
162
|
+
* (reqkit then calls getToken() again). Throwing rejects every waiting request.
|
|
163
|
+
*/
|
|
164
|
+
refresh(ctx: { token: string; response: Response }): string | void | undefined | Promise<string | void | undefined>;
|
|
165
|
+
/** Default "authorization". */
|
|
166
|
+
header?: string;
|
|
167
|
+
/** Default "Bearer". Use "" to send the bare token (e.g. for an X-API-Key header). */
|
|
168
|
+
scheme?: string;
|
|
169
|
+
/** Default: `res.status === 401`. */
|
|
170
|
+
shouldRefresh?: (response: Response) => boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Events delivered to `createFetch({ onEvent })`. */
|
|
174
|
+
export type FetchEvent =
|
|
175
|
+
| { type: "request"; url: string; method: string }
|
|
176
|
+
| { type: "attempt"; url: string; method: string; /** 1-based */ attempt: number }
|
|
177
|
+
| {
|
|
178
|
+
type: "retry";
|
|
179
|
+
url: string;
|
|
180
|
+
method: string;
|
|
181
|
+
/** The attempt that failed (1-based). */
|
|
182
|
+
attempt: number;
|
|
183
|
+
delayMs: number;
|
|
184
|
+
reason: "error" | "status";
|
|
185
|
+
status?: number;
|
|
186
|
+
error?: unknown;
|
|
187
|
+
}
|
|
188
|
+
| {
|
|
189
|
+
type: "response";
|
|
190
|
+
url: string;
|
|
191
|
+
method: string;
|
|
192
|
+
status: number;
|
|
193
|
+
ok: boolean;
|
|
194
|
+
durationMs: number;
|
|
195
|
+
/** Network attempts made (0 for a cache hit). */
|
|
196
|
+
attempts: number;
|
|
197
|
+
/** How the cache was involved, if it was. */
|
|
198
|
+
cache?: "hit" | "stale" | "revalidated" | "miss" | "stale-if-error";
|
|
199
|
+
/** Backup requests sent for this call. */
|
|
200
|
+
hedges: number;
|
|
201
|
+
/** True when a backup request produced the response. */
|
|
202
|
+
hedgeWon: boolean;
|
|
203
|
+
}
|
|
204
|
+
| { type: "error"; url?: string; method?: string; error: unknown; durationMs: number; attempts: number; hedges: number }
|
|
205
|
+
| { type: "hedge"; url: string; method: string; /** 1-based backup number */ index: number; /** ms since the call's attempt began */ afterMs: number }
|
|
206
|
+
| { type: "cache"; url: string; method: "GET"; event: CacheEvent["type"] }
|
|
207
|
+
| { type: "ratelimit"; url: string; method: string; waitedMs: number }
|
|
208
|
+
| { type: "circuit"; origin: string; state: CircuitState; previous: CircuitState }
|
|
209
|
+
| { type: "auth"; url: string; method: string; outcome: "refreshed" | "reused" | "failed"; error?: unknown };
|