@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
package/README.md
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
<a href="https://github.com/boy-offi9-inc/reqkit/actions/workflows/ci.yml"><img src="https://github.com/boy-offi9-inc/reqkit/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
10
10
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
11
11
|
<img src="https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js&logoColor=white" alt="Node >=18">
|
|
12
|
-
<img src="https://img.shields.io/badge/npm-%40boy--offi9--inc%2Freqkit-CB3837?logo=npm&logoColor=white" alt="npm package">
|
|
13
12
|
<img src="https://img.shields.io/badge/dependencies-zero-brightgreen" alt="Zero dependencies">
|
|
14
13
|
<img src="https://img.shields.io/badge/module-CJS%20%2B%20ESM-blue" alt="CJS + ESM">
|
|
15
14
|
<img src="https://img.shields.io/badge/types-included-3178C6?logo=typescript&logoColor=white" alt="TypeScript types included">
|
|
@@ -19,7 +18,7 @@
|
|
|
19
18
|
|
|
20
19
|
**Tiny, zero-dependency HTTP helpers for `fetch`, axios, or anything else — retries that respect `Retry-After`, timeouts that really cancel, and errors that make sense.**
|
|
21
20
|
|
|
22
|
-
Not a client, not an axios replacement.
|
|
21
|
+
Not a client, not an axios replacement. Use one function on its own, or let [`createFetch`](#createfetchdefaults) wire them together. You always get a plain `Response` back.
|
|
23
22
|
|
|
24
23
|
```js
|
|
25
24
|
import { createFetch } from "@boy-offi9-inc/reqkit";
|
|
@@ -48,44 +47,19 @@ const pages = await limit.map(urls, (url) =>
|
|
|
48
47
|
)
|
|
49
48
|
);
|
|
50
49
|
```
|
|
51
|
-
__
|
|
52
50
|
|
|
53
|
-
##
|
|
54
|
-
|
|
55
|
-
Try everything in your browser — retries obeying `Retry-After`, rate limiting, the circuit breaker, caching, SSE streaming, an SSE stream that reconnects itself, and a resumable download that survives a dropped connection. It runs against a fake in-memory server, so there's nothing to install and no network involved.
|
|
56
|
-
|
|
57
|
-
Run it locally: `npm run build && python3 -m http.server -d docs` → open `http://localhost:8000`. (ES modules don't load from `file://`, so it needs a server.) The repo's `pages.yml` workflow publishes the same folder to GitHub Pages — enable it once under Settings → Pages → Source: "GitHub Actions".
|
|
58
|
-
|
|
59
|
-
## Works with what you already use
|
|
60
|
-
|
|
61
|
-
Recipes for **axios, ky, got, undici/node-fetch**, testing, and LLM streaming live in [RECIPES.md](./RECIPES.md): keep your client, bolt on just the piece you're missing (`Retry-After`-aware retries, a rate limiter, a circuit breaker).
|
|
62
|
-
|
|
63
|
-
## Runtime support
|
|
64
|
-
|
|
65
|
-
| Runtime | Status |
|
|
66
|
-
|---|---|
|
|
67
|
-
| **Node 18, 20, 22, 24** | Primary target. Full test suite runs in CI on all four. |
|
|
68
|
-
| **Deno, Bun, browsers, Cloudflare Workers** | Use the dependency-free ESM bundle (`@boy-offi9-inc/reqkit/browser`, or `import … from "npm:@boy-offi9-inc/reqkit"` in Deno). Only web-standard APIs are used. CI smoke-tests the bundle on Deno, Bun and headless Chromium — **experimental** until those jobs have a track record; please open an issue if one misbehaves. |
|
|
51
|
+
## Install
|
|
69
52
|
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
import { createFetch } from "https://cdn.jsdelivr.net/npm/@boy-offi9-inc/reqkit@0.5.0/dist/reqkit.mjs";
|
|
73
|
-
</script>
|
|
53
|
+
```bash
|
|
54
|
+
npm install @boy-offi9-inc/reqkit
|
|
74
55
|
```
|
|
75
56
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
reqkit handles data a server controls (headers, streams, redirects), so the defaults are conservative:
|
|
81
|
-
|
|
82
|
-
- **Credentials stay home.** With a `baseUrl`, the default `auth` token and default `Authorization` / `Proxy-Authorization` / `Cookie` / `X-API-Key` / `X-Auth-Token` headers are only sent to that origin (plus `trustedOrigins`). Pass an absolute URL to another host and those headers are dropped — the classic "absolute URL leaks the token" bug (the one axios had) can't happen. Headers you pass *for a specific call* are deliberate and always sent. (With neither `baseUrl` nor `trustedOrigins` there's nothing to compare against, so set at least one.)
|
|
83
|
-
- **A server can't steer pagination.** `paginateLinks` refuses a `Link: rel="next"` that points at another origin (`code: "PAGINATE_CROSS_ORIGIN"`) — otherwise a hostile API could send your requests, and any credentials you pass in `init`, to a third party. Allow extra origins with `origins: [...]`.
|
|
84
|
-
- **Streams are bounded.** `readLines` / `readNDJSON` / `readSSE` run in linear time and throw `code: "LINE_TOO_LONG"` past `maxLineLength` (default 16 Mi characters) instead of buffering a never-ending line forever; `withProgress` and `downloadResumable` have `maxBytes`.
|
|
85
|
-
- **Hostile headers are cheap.** `parseLinkHeader` is linear-time and ignores headers over 64 KB.
|
|
86
|
-
- **Safe retries.** `createFetch` only retries idempotent methods (or requests with an `Idempotency-Key`), and a cross-origin redirect is left to `fetch`, which strips `Authorization` itself.
|
|
57
|
+
```js
|
|
58
|
+
import { createFetch, withRetry } from "@boy-offi9-inc/reqkit"; // ESM
|
|
59
|
+
const { createFetch, withRetry } = require("@boy-offi9-inc/reqkit"); // CommonJS
|
|
60
|
+
```
|
|
87
61
|
|
|
88
|
-
|
|
62
|
+
Requires Node 18+. TypeScript types are included.
|
|
89
63
|
|
|
90
64
|
## Pick what you need
|
|
91
65
|
|
|
@@ -101,7 +75,9 @@ What reqkit does **not** do: it doesn't block requests to private/internal addre
|
|
|
101
75
|
| Run N things at a time | [`createLimiter`](#createlimiterconcurrency) |
|
|
102
76
|
| Slow down *before* the server says 429 (obey `X-RateLimit-*`) | [`createRateLimiter`](#createratelimiteropts) |
|
|
103
77
|
| Stop hammering a service that keeps failing | [`createCircuitBreaker`](#createcircuitbreakeropts) |
|
|
104
|
-
| Cache GETs (TTL, stale-while-revalidate, ETag/304) | [`createFetch` → `cache`](#caching) |
|
|
78
|
+
| Cache GETs (TTL, stale-while-revalidate, stale-if-error, ETag/304) | [`createFetch` → `cache`](#caching) |
|
|
79
|
+
| Cut tail latency with backup requests | [`withHedge`](#withhedgefn-opts) / [`createFetch` → `hedge`](#hedged-requests) |
|
|
80
|
+
| Log or measure every request, retry and cache hit | [`createFetch` → `onEvent`](#events-and-logging) |
|
|
105
81
|
| Attach a token and refresh it on 401 — once, even with many requests in flight | [`createFetch` → `auth`](#auth-token-refresh) |
|
|
106
82
|
| Walk a paginated API (cursor, offset, `Link` headers) | [`paginate` / `paginateLinks`](#pagination-paginate-paginatepages-paginatelinks) |
|
|
107
83
|
| Resume a download after the connection drops | [`downloadResumable`](#downloadresumableurl-opts) |
|
|
@@ -110,26 +86,48 @@ What reqkit does **not** do: it doesn't block requests to private/internal addre
|
|
|
110
86
|
| Test retry/timeout code without a network | [`createMockFetch`](#createmockfetch) |
|
|
111
87
|
| All of the above, wired up | [`createFetch`](#createfetchdefaults) |
|
|
112
88
|
|
|
113
|
-
|
|
89
|
+
## Why reqkit
|
|
114
90
|
|
|
115
|
-
|
|
91
|
+
- **No client to adopt.** Standalone functions that compose with whatever you already use — fetch, axios, ky, got, undici.
|
|
92
|
+
- **It reads the server.** Retries wait for `Retry-After` / `X-RateLimit-Reset` instead of backing off blindly, and a rate limiter can slow down *before* the server says 429.
|
|
93
|
+
- **Safe by default.** Retries only touch idempotent methods, credentials never leave your origin, and parsers bound their memory and time on hostile input.
|
|
94
|
+
- **Errors that make sense.** Node's `TypeError: fetch failed`, the browser's `Failed to fetch`, axios and undici errors all normalize to one shape, and a deliberate abort is never reported as a timeout.
|
|
95
|
+
- **Zero dependencies**, CommonJS and ESM, hand-written TypeScript types.
|
|
116
96
|
|
|
117
|
-
|
|
97
|
+
## Runtime support
|
|
118
98
|
|
|
119
|
-
|
|
99
|
+
| Runtime | |
|
|
100
|
+
|---|---|
|
|
101
|
+
| **Node 18, 20, 22, 24** | Supported. |
|
|
102
|
+
| **Deno, Bun, Cloudflare Workers, browsers** | Use the dependency-free ESM bundle, `@boy-offi9-inc/reqkit/browser`. Only web-standard APIs are used. |
|
|
120
103
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
104
|
+
```js
|
|
105
|
+
// Deno
|
|
106
|
+
import { createFetch } from "npm:@boy-offi9-inc/reqkit";
|
|
107
|
+
```
|
|
125
108
|
|
|
126
|
-
|
|
109
|
+
```html
|
|
110
|
+
<!-- Browser, no build step (pin a version in production) -->
|
|
111
|
+
<script type="module">
|
|
112
|
+
import { createFetch } from "https://cdn.jsdelivr.net/npm/@boy-offi9-inc/reqkit/dist/reqkit.mjs";
|
|
113
|
+
</script>
|
|
114
|
+
```
|
|
127
115
|
|
|
128
|
-
##
|
|
116
|
+
## Playground and recipes
|
|
129
117
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
118
|
+
- **[Interactive playground](https://boy-offi9-inc.github.io/reqkit/)** — every feature running in your browser against a fake server; nothing to install.
|
|
119
|
+
- **[Recipes](./docs/recipes.md)** — using reqkit alongside axios, ky, got and undici; testing; LLM streaming.
|
|
120
|
+
|
|
121
|
+
## Security
|
|
122
|
+
|
|
123
|
+
reqkit handles data a server controls (headers, streams, redirects), so the defaults are conservative:
|
|
124
|
+
|
|
125
|
+
- **Credentials stay on your origin.** With a `baseUrl`, the default `auth` token and the default `Authorization`, `Proxy-Authorization`, `Cookie`, `X-API-Key` and `X-Auth-Token` headers are only sent to that origin (plus any `trustedOrigins`). An absolute URL to another host doesn't receive them. Headers you pass for a specific call are always sent. Without a `baseUrl` or `trustedOrigins` there is no origin to compare against, so set at least one.
|
|
126
|
+
- **Pagination stays on origin.** `paginateLinks` refuses a `Link: rel="next"` that points to another origin (`code: "PAGINATE_CROSS_ORIGIN"`); allow more with `origins: [...]`.
|
|
127
|
+
- **Bounded input.** `readLines`, `readNDJSON` and `readSSE` run in linear time and throw `LINE_TOO_LONG` past `maxLineLength` (default 16 Mi characters); `parseLinkHeader` ignores headers over 64 KB; `withProgress` and `downloadResumable` accept `maxBytes`.
|
|
128
|
+
- **Safe retries.** `createFetch` retries only idempotent methods (or requests carrying an `Idempotency-Key`). Cross-origin redirects are left to `fetch`, which strips `Authorization`.
|
|
129
|
+
|
|
130
|
+
reqkit does not block requests to private or internal addresses. If you fetch user-supplied URLs on a server, validate them first. To report a vulnerability, see [SECURITY.md](./SECURITY.md).
|
|
133
131
|
|
|
134
132
|
---
|
|
135
133
|
|
|
@@ -145,7 +143,7 @@ const data = await withRetry(
|
|
|
145
143
|
{ retries: 3, baseDelayMs: 300, onRetry: (err, attempt, delayMs) => console.log(`retry ${attempt} in ${delayMs}ms`) }
|
|
146
144
|
);
|
|
147
145
|
|
|
148
|
-
// fetch doesn't throw on 4xx/5xx
|
|
146
|
+
// fetch doesn't throw on 4xx/5xx, so list the statuses to retry:
|
|
149
147
|
const res = await withRetry(() => fetch(url), { retryStatusCodes: [429, 500, 502, 503, 504] });
|
|
150
148
|
```
|
|
151
149
|
|
|
@@ -169,16 +167,15 @@ const res = await withRetry(() => fetch(url), { retryStatusCodes: [429, 500, 502
|
|
|
169
167
|
When retries run out on a *result* (e.g. a 503), the last result is returned rather than thrown — you decide what a bad status means. Bodies of retried responses are cancelled so connections are freed.
|
|
170
168
|
|
|
171
169
|
```js
|
|
172
|
-
// Cancel a long retry/backoff sequence externally — e.g. the user navigated away
|
|
173
170
|
const controller = new AbortController();
|
|
174
171
|
const promise = withRetry(() => fetch(url), { retries: 5, signal: controller.signal });
|
|
175
172
|
controller.abort(); // rejects with RetryAbortedError, even mid-backoff
|
|
176
173
|
```
|
|
177
174
|
|
|
178
|
-
|
|
175
|
+
**`StopRetry`** — throw it from inside `fn` to stop retrying immediately:
|
|
179
176
|
|
|
180
177
|
```js
|
|
181
|
-
|
|
178
|
+
import { withRetry, StopRetry } from "@boy-offi9-inc/reqkit";
|
|
182
179
|
|
|
183
180
|
await withRetry(async () => {
|
|
184
181
|
const res = await fetch(url);
|
|
@@ -205,6 +202,30 @@ Pass your own `signal` to cancel from the outside too — it's linked into the o
|
|
|
205
202
|
await withTimeout(signal => fetch(url, { signal }), 5000, { signal: userController.signal });
|
|
206
203
|
```
|
|
207
204
|
|
|
205
|
+
### `withHedge(fn, opts)`
|
|
206
|
+
|
|
207
|
+
Insurance against the occasional slow request. It starts `fn`; if that hasn't answered after `delayMs`, it starts a backup (a *hedge*) and takes whichever succeeds first. The others are aborted, so connections are freed.
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
import { withHedge } from "@boy-offi9-inc/reqkit";
|
|
211
|
+
|
|
212
|
+
const res = await withHedge((signal) => fetch(url, { signal }), { delayMs: 150 });
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
| Option | Default | Description |
|
|
216
|
+
|---|---|---|
|
|
217
|
+
| `delayMs` | — (required) | wait before launching backup *n*: a number, or `(n) => ms` |
|
|
218
|
+
| `maxHedges` | `1` | how many backups at most |
|
|
219
|
+
| `isFailure` | `status >= 500` | results that count as failed — the next backup launches immediately |
|
|
220
|
+
| `hedgeOnError` | `true` | when an attempt *throws*, launch the next backup immediately |
|
|
221
|
+
| `onHedge` | — | `({ index, afterMs }) => void` — a backup was launched |
|
|
222
|
+
| `onWinner` | — | `(index) => void` — which attempt won (`0` = the original) |
|
|
223
|
+
| `signal` | — | cancels every attempt and rejects with the signal's reason |
|
|
224
|
+
|
|
225
|
+
`fn(signal, index)` receives an `AbortSignal` (pass it to `fetch`) and the attempt number (`0` = the original). If every attempt fails, the last failed *result* is returned (a 503 `Response` isn't thrown); if none produced a result, the last error is thrown. The bodies of responses that lose are cancelled.
|
|
226
|
+
|
|
227
|
+
Hedging sends extra requests, so only use it for calls that are safe to run twice (GETs and other idempotent calls).
|
|
228
|
+
|
|
208
229
|
### `normalizeError(err)`
|
|
209
230
|
|
|
210
231
|
One consistent error shape regardless of source (fetch, axios, node http, generic):
|
|
@@ -289,7 +310,6 @@ Coalesces concurrent calls with the same arguments into a single in-flight call
|
|
|
289
310
|
```js
|
|
290
311
|
const getUser = withDedupe((id) => fetch(`/api/users/${id}`).then(r => r.json()));
|
|
291
312
|
|
|
292
|
-
// Only one network request goes out — both callers share it.
|
|
293
313
|
const [a, b] = await Promise.all([getUser(1), getUser(1)]);
|
|
294
314
|
```
|
|
295
315
|
|
|
@@ -302,7 +322,6 @@ Plain arguments are compared by value (object key order doesn't matter); anythin
|
|
|
302
322
|
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.
|
|
303
323
|
|
|
304
324
|
```js
|
|
305
|
-
// Custom key when the default arg-based key isn't quite right
|
|
306
325
|
const fn = withDedupe((req) => doFetch(req), { keyFn: (req) => req.url });
|
|
307
326
|
```
|
|
308
327
|
|
|
@@ -479,10 +498,10 @@ String matchers match the full URL, `pathname + search`, or the bare `pathname`;
|
|
|
479
498
|
|
|
480
499
|
### `createFetch(defaults)`
|
|
481
500
|
|
|
482
|
-
|
|
501
|
+
Assembles the pieces above into one fetch-like function. It's still plain `fetch` underneath: you get a standard `Response` back, and you can supply your own `fetch`.
|
|
483
502
|
|
|
484
503
|
```js
|
|
485
|
-
|
|
504
|
+
import { createFetch, HTTPError } from "@boy-offi9-inc/reqkit";
|
|
486
505
|
|
|
487
506
|
const api = createFetch({
|
|
488
507
|
baseUrl: "https://api.example.com/v1",
|
|
@@ -517,12 +536,14 @@ const admin = api.extend({ headers: { "x-role": "admin" } }); // merges over the
|
|
|
517
536
|
| `circuitBreaker` | — | `true` / options → one breaker **per origin**; or pass a shared [`createCircuitBreaker`](#createcircuitbreakeropts), or `(url) => breaker`. An open circuit stops retries immediately. |
|
|
518
537
|
| `trustedOrigins` | — | extra origins that may receive the default credentials — see [Security](#security) |
|
|
519
538
|
| `auth` | — | attach a token and refresh it on 401 — see [Auth token refresh](#auth-token-refresh) |
|
|
539
|
+
| `hedge` | — | send a backup request when an attempt is slow — see [Hedged requests](#hedged-requests). `true` = hedge at your own recent p95 |
|
|
520
540
|
| `cache` | — | in-memory GET cache — see [Caching](#caching). `true` = 60s TTL. Per request: `cache: false` bypasses, `"reload"` refreshes |
|
|
521
541
|
| `dedupe` | `false` | merge identical concurrent `GET`/`HEAD` calls; every caller gets its own readable clone. Requests with their own `signal` are never merged. |
|
|
522
542
|
| `throwHttpErrors` | `true` | throw `HTTPError` for non-2xx (`err.status`, `err.response`, `err.url`, `err.method`) |
|
|
523
543
|
| `fetch` | `globalThis.fetch` | swap in undici, node-fetch, a test double... |
|
|
524
544
|
| `onRequest(ctx)` | — | once per call; may mutate `ctx.url` / `ctx.init` (e.g. add a header) |
|
|
525
545
|
| `onResponse(ctx)` | — | once per call, with the final response, before `HTTPError` is thrown |
|
|
546
|
+
| `onEvent(event)` | — | one callback for everything that happens — see [Events and logging](#events-and-logging) |
|
|
526
547
|
|
|
527
548
|
#### Auth token refresh
|
|
528
549
|
|
|
@@ -546,6 +567,51 @@ await Promise.all(urls.map((u) => api.json(u))); // 20 requests, all 401 → one
|
|
|
546
567
|
- **Options:** `header` (default `"authorization"`), `scheme` (default `"Bearer"`, `""` for a bare token such as an `X-API-Key`), `shouldRefresh(response)` (default: status 401). `refresh({ token, response })` may return the new token or just update your store. If `refresh` throws, every waiting request rejects with that error — and the next request tries again.
|
|
547
568
|
- `extend()` children that share the same `auth` object share one refresh; the cache keys entries per token.
|
|
548
569
|
|
|
570
|
+
#### Hedged requests
|
|
571
|
+
|
|
572
|
+
```js
|
|
573
|
+
const api = createFetch({ baseUrl: "https://api.example.com", hedge: true }); // hedge at your own recent p95
|
|
574
|
+
const api2 = createFetch({ baseUrl: "https://api.example.com", hedge: { delayMs: 200 } }); // or a fixed delay
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
If an attempt hasn't answered after the delay, a backup request goes out and the first good response wins; the slower request is cancelled.
|
|
578
|
+
|
|
579
|
+
- **Idempotent only.** GET, HEAD, OPTIONS, PUT and DELETE are hedged; a POST isn't unless it carries an `Idempotency-Key` (or you list the method in `methods`). Stream bodies are never hedged.
|
|
580
|
+
- **`delayMs: "auto"`** (the default for `hedge: true`) waits for your own recent `percentile` latency (default 0.95) per origin, never less than `minDelayMs` (20), and uses `fallbackDelayMs` (300) until `minSamples` (10) responses have been seen. At p95 that's roughly 5% extra requests for a much shorter tail.
|
|
581
|
+
- **Failures don't wait.** A 5xx response or a thrown error launches the backup immediately.
|
|
582
|
+
- **Backups are real requests:** each one goes through the rate limiter. Hedging is applied per attempt, inside retries, and turns off request de-duplication for the hedged call.
|
|
583
|
+
- Override per call with `hedge: false` or `hedge: { delayMs: 100 }`. `maxHedges` (default 1) allows more backups.
|
|
584
|
+
|
|
585
|
+
#### Events and logging
|
|
586
|
+
|
|
587
|
+
One callback receives everything that happens, so you can plug reqkit into your logger or metrics without wrapping anything.
|
|
588
|
+
|
|
589
|
+
```js
|
|
590
|
+
const api = createFetch({
|
|
591
|
+
baseUrl: "https://api.example.com",
|
|
592
|
+
onEvent: (e) => {
|
|
593
|
+
if (e.type === "response") metrics.timing("http.request", e.durationMs, { status: e.status, cache: e.cache });
|
|
594
|
+
if (e.type === "retry") log.warn(`retry #${e.attempt} in ${e.delayMs}ms (${e.reason})`);
|
|
595
|
+
if (e.type === "error") log.error(e.error, { url: e.url });
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
| `type` | Emitted when | Fields |
|
|
601
|
+
|---|---|---|
|
|
602
|
+
| `request` | a call starts | `url`, `method` |
|
|
603
|
+
| `attempt` | a network attempt starts | `url`, `method`, `attempt` (1-based) |
|
|
604
|
+
| `retry` | an attempt failed and another will follow | `url`, `method`, `attempt`, `delayMs`, `reason` (`"error"` \| `"status"`), `status?`, `error?` |
|
|
605
|
+
| `response` | the final response arrives (including non-2xx, before `HTTPError` is thrown) | `url`, `method`, `status`, `ok`, `durationMs`, `attempts`, `hedges`, `hedgeWon`, `cache?` (`"hit"` \| `"stale"` \| `"revalidated"` \| `"miss"` \| `"stale-if-error"`) |
|
|
606
|
+
| `error` | the call fails without a response — network error, timeout, abort, open circuit | `url?`, `method?`, `error`, `durationMs`, `attempts` |
|
|
607
|
+
| `hedge` | a backup request was sent | `url`, `method`, `index`, `afterMs` |
|
|
608
|
+
| `cache` | cache activity | `url`, `method`, `event` (`hit`, `stale`, `revalidated`, `miss`, `store`, `stale-if-error`) |
|
|
609
|
+
| `ratelimit` | a call waited on the rate limiter | `url`, `method`, `waitedMs` |
|
|
610
|
+
| `circuit` | a circuit breaker changed state (breakers created from options — one per origin) | `origin`, `state`, `previous` |
|
|
611
|
+
| `auth` | a 401 triggered token handling | `url`, `method`, `outcome` (`"refreshed"` \| `"reused"` \| `"failed"`), `error?` |
|
|
612
|
+
|
|
613
|
+
Handlers can be sync or async, and anything they throw is ignored — logging never breaks a request. `extend()` children inherit the handler or replace it. A cache hit makes no network attempt, so its `response` event has `attempts: 0`.
|
|
614
|
+
|
|
549
615
|
#### Caching
|
|
550
616
|
|
|
551
617
|
```js
|
|
@@ -564,10 +630,13 @@ await api.json("/config"); // fresh → served from memory, no request
|
|
|
564
630
|
|---|---|---|
|
|
565
631
|
| `ttl` | `60000` | serve without asking the server for this long |
|
|
566
632
|
| `staleWhileRevalidate` | `0` | after the TTL, keep serving the stale copy this long while refreshing in the background |
|
|
633
|
+
| `staleIfError` | `0` | after the TTL, serve the stale copy for this long **when the origin is failing** (network error, timeout, open circuit, 5xx) instead of surfacing the error |
|
|
567
634
|
| `maxEntries` | `100` | size of the built-in LRU store |
|
|
568
635
|
| `maxBodyBytes` | `1 MiB` | bigger responses aren't cached |
|
|
569
636
|
| `store` | built-in LRU | bring your own (`get`/`set`/`delete`, sync or async) — Redis, IndexedDB, a `Map`… |
|
|
570
|
-
| `onEvent` | — | `({ type: "hit" \| "stale" \| "revalidated" \| "miss" \| "store", url })`
|
|
637
|
+
| `onEvent` | — | `({ type: "hit" \| "stale" \| "revalidated" \| "miss" \| "store" \| "stale-if-error", url })` — cache-only; see also [`onEvent`](#events-and-logging) on `createFetch` |
|
|
638
|
+
|
|
639
|
+
With `staleIfError: 10 * 60_000`, a cached copy keeps being served for up to 10 minutes past its TTL while the origin is down, instead of your callers seeing the failure. 4xx responses and deliberate aborts are never masked, and `cache: "reload"` always goes to the origin.
|
|
571
640
|
|
|
572
641
|
Only `GET` `200` responses are cached; `Cache-Control: no-store` and `Vary: *` are respected. **Every request header is part of the cache key**, so a response fetched with one user's `Authorization` can never be served to another. Each hit is a fresh `Response`, so bodies can be read as many times as you like. `extend()` children share the parent's cache, limiter and breakers.
|
|
573
642
|
|
|
@@ -575,15 +644,14 @@ Only `GET` `200` responses are cached; `Cache-Control: no-store` and `Vary: *` a
|
|
|
575
644
|
|
|
576
645
|
---
|
|
577
646
|
|
|
578
|
-
##
|
|
647
|
+
## Prior art
|
|
579
648
|
|
|
580
|
-
-
|
|
581
|
-
- **CJS + ESM.** `require()` and `import` both work.
|
|
582
|
-
- **Composable, not a client.** Every function takes and returns plain values (`Response`, plain objects, `Uint8Array`) — nothing proprietary to learn.
|
|
583
|
-
- **TypeScript types included**, hand-written (no build step to go wrong), with separate entries for CJS and ESM.
|
|
649
|
+
reqkit's building blocks are small independent implementations of ideas from [`p-retry`](https://github.com/sindresorhus/p-retry), [`ky`](https://github.com/sindresorhus/ky), [`ofetch`](https://github.com/unjs/ofetch), [`got`](https://github.com/sindresorhus/got), [`axios-retry`](https://github.com/softonic/axios-retry), [`p-limit`](https://github.com/sindresorhus/p-limit), [`opossum`](https://github.com/nodeshift/opossum), [`bottleneck`](https://github.com/SGrondin/bottleneck), [`p-throttle`](https://github.com/sindresorhus/p-throttle), [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) and [`nock`](https://github.com/nock/nock), plus the HTTP caching RFCs and the AWS "full jitter" backoff.
|
|
584
650
|
|
|
585
|
-
|
|
651
|
+
## Contributing
|
|
586
652
|
|
|
587
|
-
|
|
653
|
+
Issues and pull requests are welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md) and the [architecture overview](./docs/architecture.md).
|
|
654
|
+
|
|
655
|
+
## License
|
|
588
656
|
|
|
589
|
-
|
|
657
|
+
[MIT](./LICENSE) © Boy Offi9
|