@fetchkit/ffetch 4.3.0 → 5.0.1

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 CHANGED
@@ -15,18 +15,22 @@
15
15
 
16
16
  ffetch can wrap any fetch-compatible implementation (native fetch, node-fetch, undici, or framework-provided fetch), making it flexible for SSR, edge, and custom environments.
17
17
 
18
+ ffetch uses a plugin architecture for optional features, so you only include what you need.
19
+
18
20
  **Key Features:**
19
21
 
20
22
  - **Timeouts** – per-request or global
21
23
  - **Retries** – exponential backoff + jitter
22
- - **Circuit breaker** – automatic failure protection
23
- - **Deduplication** – automatic deduping of in-flight identical requests
24
+ - **Abort-aware retries** – aborting during backoff cancels immediately
25
+ - **Plugin architecture** – extensible lifecycle-based plugins for optional behavior
24
26
  - **Hooks** – logging, auth, metrics, request/response transformation
25
27
  - **Pending requests** – real-time monitoring of active requests
26
28
  - **Per-request overrides** – customize behavior on a per-request basis
27
29
  - **Universal** – Node.js, Browser, Cloudflare Workers, React Native
28
30
  - **Zero runtime deps** – ships as dual ESM/CJS
29
31
  - **Configurable error handling** – custom error types and `throwOnHttpError` flag to throw on HTTP errors
32
+ - **Circuit breaker plugin (optional, prebuilt)** – automatic failure protection
33
+ - **Deduplication plugin (optional, prebuilt)** – automatic deduping of in-flight identical requests
30
34
 
31
35
  ## Quick Start
32
36
 
@@ -39,13 +43,14 @@ npm install @fetchkit/ffetch
39
43
  ### Basic Usage
40
44
 
41
45
  ```typescript
42
- import createClient from '@fetchkit/ffetch'
46
+ import { createClient } from '@fetchkit/ffetch'
47
+ import { dedupePlugin } from '@fetchkit/ffetch/plugins/dedupe'
43
48
 
44
- // Create a client with timeout, retries, and deduplication
49
+ // Create a client with timeout, retries, and deduplication plugin
45
50
  const api = createClient({
46
51
  timeout: 5000,
47
52
  retries: 3,
48
- dedupe: true, // Enable deduplication globally
53
+ plugins: [dedupePlugin()],
49
54
  retryDelay: ({ attempt }) => 2 ** attempt * 100 + Math.random() * 100,
50
55
  })
51
56
 
@@ -64,7 +69,7 @@ const [r1, r2] = await Promise.all([p1, p2])
64
69
 
65
70
  ```typescript
66
71
  // Example: SvelteKit, Next.js, Nuxt, or node-fetch
67
- import createClient from '@fetchkit/ffetch'
72
+ import { createClient } from '@fetchkit/ffetch'
68
73
 
69
74
  // Pass your framework's fetch implementation
70
75
  const api = createClient({
@@ -84,19 +89,31 @@ const response = await api('/api/data')
84
89
 
85
90
  ```typescript
86
91
  // Production-ready client with error handling and monitoring
92
+ import { createClient } from '@fetchkit/ffetch'
93
+ import { dedupePlugin } from '@fetchkit/ffetch/plugins/dedupe'
94
+ import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit'
95
+
87
96
  const client = createClient({
88
97
  timeout: 10000,
89
98
  retries: 2,
90
- dedupe: true,
91
- dedupeHashFn: (params) => `${params.method}|${params.url}|${params.body}`,
92
- circuit: { threshold: 5, reset: 30000 },
93
99
  fetchHandler: fetch, // Use custom fetch if needed
100
+ plugins: [
101
+ dedupePlugin({
102
+ hashFn: (params) => `${params.method}|${params.url}|${params.body}`,
103
+ ttl: 30_000,
104
+ sweepInterval: 5_000,
105
+ }),
106
+ circuitPlugin({
107
+ threshold: 5,
108
+ reset: 30_000,
109
+ onCircuitOpen: (req) => console.warn('Circuit opened due to:', req.url),
110
+ onCircuitClose: (req) => console.info('Circuit closed after:', req.url),
111
+ }),
112
+ ],
94
113
  hooks: {
95
114
  before: async (req) => console.log('→', req.url),
96
115
  after: async (req, res) => console.log('←', res.status),
97
116
  onError: async (req, err) => console.error('Error:', err.message),
98
- onCircuitOpen: (req) => console.warn('Circuit opened due to:', req.url),
99
- onCircuitClose: (req) => console.info('Circuit closed after:', req.url),
100
117
  },
101
118
  })
102
119
 
@@ -130,6 +147,7 @@ Native `fetch`'s controversial behavior of not throwing errors for HTTP error st
130
147
  | --------------------------------------------- | ------------------------------------------------------------------------- |
131
148
  | **[Complete Documentation](./docs/index.md)** | **Start here** - Documentation index and overview |
132
149
  | **[API Reference](./docs/api.md)** | Complete API documentation and configuration options |
150
+ | **[Plugin Architecture](./docs/plugins.md)** | Plugin lifecycle, custom plugin authoring, and integration patterns |
133
151
  | **[Deduplication](./docs/deduplication.md)** | How deduplication works, hash config, optional TTL cleanup, limitations |
134
152
  | **[Error Handling](./docs/errorhandling.md)** | Strategies for managing errors, including `throwOnHttpError` |
135
153
  | **[Advanced Features](./docs/advanced.md)** | Per-request overrides, pending requests, circuit breakers, custom errors |
@@ -139,12 +157,12 @@ Native `fetch`'s controversial behavior of not throwing errors for HTTP error st
139
157
 
140
158
  ## Environment Requirements
141
159
 
142
- `ffetch` requires modern AbortSignal APIs:
160
+ `ffetch` works best with native `AbortSignal.any` support:
143
161
 
144
- - **Node.js 20.6+** (for AbortSignal.any)
145
- - **Modern browsers** (Chrome 117+, Firefox 117+, Safari 17+, Edge 117+)
162
+ - **Node.js 20.6+** (native `AbortSignal.any`)
163
+ - **Modern browsers with `AbortSignal.any`** (for example: Chrome 117+, Firefox 117+, Safari 17+, Edge 117+)
146
164
 
147
- If your environment does not support `AbortSignal.any` (Node.js < 20.6, older browsers), you **must install a polyfill** before using ffetch. See the [compatibility guide](./docs/compatibility.md) for instructions.
165
+ If your environment does not support `AbortSignal.any` (Node.js < 20.6, older browsers), you can still use ffetch by installing an `AbortSignal.any` polyfill. `AbortSignal.timeout` is optional because ffetch includes an internal timeout fallback. See the [compatibility guide](./docs/compatibility.md) for instructions.
148
166
 
149
167
  **Custom fetch support:**
150
168
  You can pass any fetch-compatible implementation (native fetch, node-fetch, undici, SvelteKit, Next.js, Nuxt, or a polyfill) via the `fetchHandler` option. This makes ffetch fully compatible with SSR, edge, metaframework environments, custom backends, and test runners.
@@ -161,7 +179,7 @@ npm install abort-controller-x
161
179
 
162
180
  ```html
163
181
  <script type="module">
164
- import createClient from 'https://unpkg.com/@fetchkit/ffetch/dist/index.min.js'
182
+ import { createClient } from 'https://unpkg.com/@fetchkit/ffetch/dist/index.min.js'
165
183
 
166
184
  const api = createClient({ timeout: 5000 })
167
185
  const data = await api('/api/data').then((r) => r.json())
@@ -170,9 +188,9 @@ npm install abort-controller-x
170
188
 
171
189
  ## Deduplication Limitations
172
190
 
173
- - Deduplication is **off** by default. Enable it via the `dedupe` option.
191
+ - Deduplication is **off** by default. Enable it via `plugins: [dedupePlugin()]`.
174
192
  - The default hash function is `dedupeRequestHash`, which handles common body types and skips deduplication for streams and FormData.
175
- - Optional stale-entry cleanup: `dedupeTTL` enables map-entry eviction, and `dedupeSweepInterval` controls how often eviction runs. TTL eviction only removes dedupe keys; it does not reject already in-flight promises.
193
+ - Optional stale-entry cleanup: `dedupePlugin({ ttl, sweepInterval })` enables map-entry eviction. TTL eviction only removes dedupe keys; it does not reject already in-flight promises.
176
194
  - **Stream bodies** (`ReadableStream`, `FormData`): Deduplication is skipped for requests with these body types, as they cannot be reliably hashed or replayed.
177
195
  - **Non-idempotent requests**: Use deduplication with caution for non-idempotent methods (e.g., POST), as it may suppress multiple intended requests.
178
196
  - **Custom hash function**: Ensure your hash function uniquely identifies requests to avoid accidental deduplication.
@@ -185,6 +203,7 @@ See [deduplication.md](./docs/deduplication.md) for full details.
185
203
  | -------------------- | ------------------------- | -------------------- | -------------------------------------------------------------------------------------- |
186
204
  | Timeouts | ❌ Manual AbortController | ✅ Built-in | ✅ Built-in with fallbacks |
187
205
  | Retries | ❌ Manual implementation | ❌ Manual or plugins | ✅ Smart exponential backoff |
206
+ | Plugin Architecture | ❌ Not available | ⚠️ Interceptors only | ✅ First-class plugin pipeline (optional built-in + custom plugins) |
188
207
  | Circuit Breaker | ❌ Not available | ❌ Manual or plugins | ✅ Automatic failure protection |
189
208
  | Deduplication | ❌ Not available | ❌ Not available | ✅ Automatic deduplication of in-flight identical requests |
190
209
  | Request Monitoring | ❌ Manual tracking | ❌ Manual tracking | ✅ Built-in pending requests |
package/dist/index.cjs CHANGED
@@ -82,43 +82,10 @@ __export(index_exports, {
82
82
  NetworkError: () => NetworkError,
83
83
  RetryLimitError: () => RetryLimitError,
84
84
  TimeoutError: () => TimeoutError,
85
- createClient: () => createClient,
86
- default: () => index_default
85
+ createClient: () => createClient
87
86
  });
88
87
  module.exports = __toCommonJS(index_exports);
89
88
 
90
- // src/dedupeRequestHash.ts
91
- function dedupeRequestHash(params) {
92
- const { method, url, body } = params;
93
- let bodyString = "";
94
- if (body instanceof FormData) {
95
- return void 0;
96
- }
97
- if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) {
98
- return void 0;
99
- }
100
- if (typeof body === "string") {
101
- bodyString = body;
102
- } else if (body instanceof URLSearchParams) {
103
- bodyString = body.toString();
104
- } else if (body instanceof ArrayBuffer) {
105
- bodyString = Buffer.from(body).toString("base64");
106
- } else if (body instanceof Uint8Array) {
107
- bodyString = Buffer.from(body).toString("base64");
108
- } else if (body instanceof Blob) {
109
- bodyString = `[blob:${body.type}:${body.size}]`;
110
- } else if (body == null) {
111
- bodyString = "";
112
- } else {
113
- try {
114
- bodyString = JSON.stringify(body);
115
- } catch {
116
- bodyString = "[unserializable-body]";
117
- }
118
- }
119
- return `${method.toUpperCase()}|${url}|${bodyString}`;
120
- }
121
-
122
89
  // src/retry.ts
123
90
  var defaultDelay = (ctx) => {
124
91
  const retryAfter = ctx.response?.headers.get("Retry-After");
@@ -130,7 +97,29 @@ var defaultDelay = (ctx) => {
130
97
  }
131
98
  return 2 ** ctx.attempt * 200 + Math.random() * 100;
132
99
  };
133
- async function retry(fn, retries, delay, shouldRetry2 = () => true, request) {
100
+ function waitForRetryDelay(ms, signal) {
101
+ if (ms <= 0) return Promise.resolve();
102
+ return new Promise((resolve) => {
103
+ if (!signal) {
104
+ setTimeout(resolve, ms);
105
+ return;
106
+ }
107
+ if (signal.aborted) {
108
+ resolve();
109
+ return;
110
+ }
111
+ const onAbort = () => {
112
+ clearTimeout(timer);
113
+ resolve();
114
+ };
115
+ const timer = setTimeout(() => {
116
+ signal.removeEventListener("abort", onAbort);
117
+ resolve();
118
+ }, ms);
119
+ signal.addEventListener("abort", onAbort, { once: true });
120
+ });
121
+ }
122
+ async function retry(fn, retries, delay, shouldRetry2 = () => true, request, signal) {
134
123
  let lastErr;
135
124
  let lastRes;
136
125
  for (let i = 0; i <= retries; i++) {
@@ -146,7 +135,7 @@ async function retry(fn, retries, delay, shouldRetry2 = () => true, request) {
146
135
  ctx.error = void 0;
147
136
  if (i < retries && shouldRetry2(ctx)) {
148
137
  const wait = typeof delay === "function" ? delay(ctx) : delay;
149
- await new Promise((r) => setTimeout(r, wait));
138
+ await waitForRetryDelay(wait, signal);
150
139
  continue;
151
140
  }
152
141
  return lastRes;
@@ -155,7 +144,7 @@ async function retry(fn, retries, delay, shouldRetry2 = () => true, request) {
155
144
  ctx.error = err;
156
145
  if (i === retries || !shouldRetry2(ctx)) throw err;
157
146
  const wait = typeof delay === "function" ? delay(ctx) : delay;
158
- await new Promise((r) => setTimeout(r, wait));
147
+ await waitForRetryDelay(wait, signal);
159
148
  }
160
149
  }
161
150
  throw lastErr;
@@ -171,128 +160,49 @@ function shouldRetry(ctx) {
171
160
  return response.status >= 500 || response.status === 429;
172
161
  }
173
162
 
174
- // src/circuit.ts
175
- init_error();
176
- var CircuitBreaker = class {
177
- constructor(threshold, resetTimeout) {
178
- this.threshold = threshold;
179
- this.resetTimeout = resetTimeout;
180
- this.failures = 0;
181
- this.nextAttempt = 0;
182
- this.isOpen = false;
183
- }
184
- // Returns true if the circuit breaker is currently open (blocking requests).
185
- get open() {
186
- return this.isOpen;
187
- }
188
- // Call this after each request to record the result and update circuit state.
189
- // Returns true if the result was counted as a failure.
190
- recordResult(response, error, req) {
191
- if (error && !(error instanceof RetryLimitError)) {
192
- this.setLastOpenRequest(req);
193
- this.onFailure();
194
- return true;
195
- }
196
- if (response && (response.status >= 500 || response.status === 429)) {
197
- this.setLastOpenRequest(req);
198
- this.onFailure();
199
- return true;
200
- }
201
- if (req) this.setLastSuccessRequest(req);
202
- this.onSuccess();
203
- return false;
204
- }
205
- setHooks(hooks) {
206
- this.hooks = hooks;
207
- }
208
- setLastOpenRequest(req) {
209
- this.lastOpenRequest = req;
210
- }
211
- setLastSuccessRequest(req) {
212
- this.lastSuccessRequest = req;
213
- }
214
- async invoke(fn) {
215
- if (Date.now() < this.nextAttempt)
216
- throw new CircuitOpenError("Circuit is open");
217
- try {
218
- const res = await fn();
219
- this.onSuccess();
220
- return res;
221
- } catch (err) {
222
- this.onFailure();
223
- throw err;
224
- }
225
- }
226
- onSuccess() {
227
- const wasOpen = this.isOpen;
228
- this.failures = 0;
229
- if (wasOpen) {
230
- this.isOpen = false;
231
- if (this.hooks?.onCircuitClose && this.lastSuccessRequest) {
232
- this.hooks.onCircuitClose(this.lastSuccessRequest);
233
- }
234
- }
235
- this.lastSuccessRequest = void 0;
236
- }
237
- onFailure() {
238
- this.failures++;
239
- if (this.failures >= this.threshold) {
240
- this.nextAttempt = Date.now() + this.resetTimeout;
241
- this.isOpen = true;
242
- if (this.hooks?.onCircuitOpen && this.lastOpenRequest) {
243
- this.hooks.onCircuitOpen(this.lastOpenRequest);
244
- }
245
- }
246
- }
247
- };
248
-
249
163
  // src/client.ts
250
164
  init_error();
251
165
  function createClient(opts = {}) {
252
- let dedupeSweeper;
253
- function startDedupeSweeper() {
254
- if (dedupeSweeper || !dedupeTTL) return;
255
- dedupeSweeper = setInterval(() => {
256
- const now = Date.now();
257
- for (const [key, entry] of dedupeMap.entries()) {
258
- if (now - entry.createdAt > dedupeTTL) {
259
- dedupeMap.delete(key);
260
- }
261
- }
262
- if (dedupeMap.size === 0 && dedupeSweeper) {
263
- clearInterval(dedupeSweeper);
264
- dedupeSweeper = void 0;
265
- }
266
- }, dedupeSweepInterval);
267
- }
268
- function stopDedupeSweeperIfIdle() {
269
- if (dedupeMap.size === 0 && dedupeSweeper) {
270
- clearInterval(dedupeSweeper);
271
- dedupeSweeper = void 0;
272
- }
273
- }
274
- const dedupeMap = /* @__PURE__ */ new Map();
275
166
  const {
276
167
  timeout: clientDefaultTimeout = 5e3,
277
168
  retries: clientDefaultRetries = 0,
278
169
  retryDelay: clientDefaultRetryDelay = defaultDelay,
279
170
  shouldRetry: clientDefaultShouldRetry = shouldRetry,
280
171
  hooks: clientDefaultHooks = {},
281
- circuit: clientDefaultCircuit,
282
172
  fetchHandler,
283
- dedupe = false,
284
- dedupeHashFn = dedupeRequestHash,
285
- dedupeTTL,
286
- dedupeSweepInterval = 5e3
173
+ plugins: inputPlugins = []
287
174
  } = opts;
288
- const breaker = clientDefaultCircuit ? new CircuitBreaker(
289
- clientDefaultCircuit.threshold,
290
- clientDefaultCircuit.reset
291
- ) : null;
292
- if (breaker && (clientDefaultHooks.onCircuitClose || clientDefaultHooks.onCircuitOpen)) {
293
- breaker.setHooks({
294
- onCircuitClose: clientDefaultHooks.onCircuitClose,
295
- onCircuitOpen: clientDefaultHooks.onCircuitOpen
175
+ const extensionDescriptors = /* @__PURE__ */ Object.create(null);
176
+ const plugins = inputPlugins.map((plugin, index) => ({ plugin, index })).sort((a, b) => {
177
+ const aOrder = a.plugin.order ?? 0;
178
+ const bOrder = b.plugin.order ?? 0;
179
+ if (aOrder !== bOrder) return aOrder - bOrder;
180
+ return a.index - b.index;
181
+ }).map((entry) => entry.plugin);
182
+ for (const plugin of plugins) {
183
+ plugin.setup?.({
184
+ defineExtension: (key, descriptor) => {
185
+ const propertyKey = key;
186
+ if (propertyKey in extensionDescriptors) {
187
+ throw new Error(
188
+ `Plugin extension collision for property "${String(propertyKey)}"`
189
+ );
190
+ }
191
+ if ("get" in descriptor) {
192
+ extensionDescriptors[propertyKey] = {
193
+ get: descriptor.get,
194
+ enumerable: descriptor.enumerable ?? true,
195
+ configurable: false
196
+ };
197
+ return;
198
+ }
199
+ extensionDescriptors[propertyKey] = {
200
+ value: descriptor.value,
201
+ writable: false,
202
+ enumerable: descriptor.enumerable ?? true,
203
+ configurable: false
204
+ };
205
+ }
296
206
  });
297
207
  }
298
208
  const pendingRequests = [];
@@ -302,55 +212,39 @@ function createClient(opts = {}) {
302
212
  }
303
213
  }
304
214
  const client = async (input, init = {}) => {
305
- const effectiveDedupe = typeof init.dedupe !== "undefined" ? init.dedupe : dedupe;
306
- const effectiveDedupeHashFn = init.dedupeHashFn || dedupeHashFn;
307
- let dedupeKey;
308
215
  let request = new Request(input, init);
309
- if (effectiveDedupe) {
310
- dedupeKey = effectiveDedupeHashFn({
311
- method: request.method,
312
- url: request.url,
313
- body: init.body ?? null,
314
- headers: request.headers,
315
- signal: init.signal === void 0 || init.signal === null ? void 0 : init.signal,
316
- requestInit: init,
317
- request
318
- });
319
- if (dedupeKey) {
320
- if (dedupeMap.has(dedupeKey)) {
321
- return dedupeMap.get(dedupeKey).promise;
322
- }
323
- let settled = false;
324
- let resolveFn;
325
- let rejectFn;
326
- const inFlightPromise = new Promise((resolve, reject) => {
327
- resolveFn = (value) => {
328
- if (!settled) {
329
- settled = true;
330
- resolve(value);
331
- }
332
- };
333
- rejectFn = (reason) => {
334
- if (!settled) {
335
- settled = true;
336
- reject(reason);
337
- }
338
- };
339
- });
340
- dedupeMap.set(dedupeKey, {
341
- promise: inFlightPromise,
342
- resolve: resolveFn,
343
- reject: rejectFn,
344
- createdAt: Date.now()
345
- });
346
- if (dedupeTTL) startDedupeSweeper();
347
- }
348
- }
349
216
  const effectiveHooks = { ...clientDefaultHooks, ...init.hooks || {} };
350
217
  if (effectiveHooks.transformRequest) {
351
218
  request = await effectiveHooks.transformRequest(request);
352
219
  }
353
220
  await effectiveHooks.before?.(request);
221
+ const effectiveRetries = init.retries ?? clientDefaultRetries;
222
+ const effectiveRetryDelay = typeof init.retryDelay !== "undefined" ? init.retryDelay : clientDefaultRetryDelay;
223
+ const effectiveShouldRetry = init.shouldRetry ?? clientDefaultShouldRetry;
224
+ const effectiveTimeout = init.timeout ?? clientDefaultTimeout;
225
+ const userSignal = init.signal;
226
+ const transformedSignal = request.signal;
227
+ const pluginContext = {
228
+ request,
229
+ init,
230
+ state: /* @__PURE__ */ Object.create(null),
231
+ metadata: {
232
+ startedAt: Date.now(),
233
+ timeoutMs: effectiveTimeout,
234
+ signals: {
235
+ user: userSignal === void 0 || userSignal === null ? void 0 : userSignal,
236
+ transformed: transformedSignal
237
+ },
238
+ retry: {
239
+ configuredRetries: effectiveRetries,
240
+ configuredDelay: effectiveRetryDelay,
241
+ attempt: 0
242
+ }
243
+ }
244
+ };
245
+ for (const plugin of plugins) {
246
+ await plugin.preRequest?.(pluginContext);
247
+ }
354
248
  const effectiveThrowOnHttpError = typeof init.throwOnHttpError !== "undefined" ? init.throwOnHttpError : opts.throwOnHttpError ?? false;
355
249
  function createTimeoutSignal(timeout) {
356
250
  if (typeof AbortSignal?.timeout === "function") {
@@ -365,14 +259,12 @@ function createClient(opts = {}) {
365
259
  );
366
260
  return controller2.signal;
367
261
  }
368
- const effectiveTimeout = init.timeout ?? clientDefaultTimeout;
369
- const userSignal = init.signal;
370
- const transformedSignal = request.signal;
371
262
  let timeoutSignal = void 0;
372
263
  let combinedSignal = void 0;
373
264
  let controller = void 0;
374
265
  if (effectiveTimeout > 0) {
375
266
  timeoutSignal = createTimeoutSignal(effectiveTimeout);
267
+ pluginContext.metadata.signals.timeout = timeoutSignal;
376
268
  }
377
269
  const signals = [];
378
270
  if (userSignal) signals.push(userSignal);
@@ -392,14 +284,16 @@ function createClient(opts = {}) {
392
284
  combinedSignal = AbortSignal.any(signals);
393
285
  controller = new AbortController();
394
286
  }
287
+ pluginContext.metadata.signals.combined = combinedSignal;
395
288
  const retryWithHooks = async () => {
396
- const effectiveRetries = init.retries ?? clientDefaultRetries;
397
- const effectiveRetryDelay = typeof init.retryDelay !== "undefined" ? init.retryDelay : clientDefaultRetryDelay;
398
- const effectiveShouldRetry = init.shouldRetry ?? clientDefaultShouldRetry;
399
289
  let attempt = 0;
400
290
  const shouldRetryWithHook = (ctx) => {
401
291
  attempt = ctx.attempt;
292
+ pluginContext.metadata.retry.attempt = attempt;
293
+ pluginContext.metadata.retry.lastError = ctx.error;
294
+ pluginContext.metadata.retry.lastResponse = ctx.response;
402
295
  const retrying = effectiveShouldRetry(ctx);
296
+ pluginContext.metadata.retry.shouldRetryResult = retrying;
403
297
  if (retrying && attempt <= effectiveRetries) {
404
298
  effectiveHooks.onRetry?.(
405
299
  request,
@@ -445,12 +339,10 @@ function createClient(opts = {}) {
445
339
  const handler = init.fetchHandler ?? fetchHandler ?? fetch;
446
340
  const response = await handler(reqWithSignal);
447
341
  lastResponse = response;
448
- if (breaker && (response.status >= 500 || response.status === 429)) {
449
- breaker.recordResult(response, void 0, request);
450
- }
342
+ pluginContext.metadata.retry.lastResponse = response;
451
343
  return response;
452
344
  } catch (err) {
453
- if (breaker) breaker.recordResult(void 0, err, request);
345
+ pluginContext.metadata.retry.lastError = err;
454
346
  if (err instanceof DOMException && err.name === "AbortError") {
455
347
  if (timeoutSignal?.aborted && (!userSignal || !userSignal.aborted)) {
456
348
  effectiveHooks.onTimeout?.(request);
@@ -475,7 +367,8 @@ function createClient(opts = {}) {
475
367
  effectiveRetries,
476
368
  effectiveRetryDelay,
477
369
  shouldRetryWithHook,
478
- request
370
+ request,
371
+ combinedSignal
479
372
  );
480
373
  if (effectiveHooks.transformResponse) {
481
374
  res = await effectiveHooks.transformResponse(res, request);
@@ -491,6 +384,7 @@ function createClient(opts = {}) {
491
384
  }
492
385
  return res;
493
386
  } catch (err) {
387
+ pluginContext.metadata.retry.lastError = err;
494
388
  if (lastResponse) {
495
389
  const resp = lastResponse;
496
390
  if (effectiveThrowOnHttpError && (resp.status >= 400 && resp.status < 500 && resp.status !== 429 || resp.status >= 500 || resp.status === 429)) {
@@ -528,47 +422,39 @@ function createClient(opts = {}) {
528
422
  throw retryErr;
529
423
  }
530
424
  };
531
- const actualPromise = breaker ? breaker.invoke(retryWithHooks).catch(async (err) => {
532
- if (err instanceof CircuitOpenError) {
533
- await effectiveHooks.onCircuitOpen?.(request);
534
- await effectiveHooks.onError?.(request, err);
535
- await effectiveHooks.onComplete?.(request, void 0, err);
536
- } else {
537
- await effectiveHooks.onError?.(request, err);
538
- await effectiveHooks.onComplete?.(request, void 0, err);
539
- }
540
- throw err;
541
- }) : retryWithHooks();
542
- if (effectiveDedupe && dedupeKey && dedupeMap.has(dedupeKey)) {
543
- const entry = dedupeMap.get(dedupeKey);
544
- if (entry) {
545
- actualPromise.then(
546
- (result) => entry.resolve(result),
547
- (error) => entry.reject(error)
548
- );
549
- dedupeMap.set(dedupeKey, {
550
- promise: actualPromise,
551
- resolve: entry.resolve,
552
- reject: entry.reject,
553
- createdAt: entry.createdAt
554
- });
425
+ const baseDispatch = async () => retryWithHooks();
426
+ let dispatch = baseDispatch;
427
+ for (let i = plugins.length - 1; i >= 0; i--) {
428
+ const plugin = plugins[i];
429
+ if (plugin.wrapDispatch) {
430
+ dispatch = plugin.wrapDispatch(dispatch);
555
431
  }
556
432
  }
433
+ const actualPromise = dispatch(pluginContext).then(async (response) => {
434
+ for (const plugin of plugins) {
435
+ await plugin.onSuccess?.(pluginContext, response);
436
+ }
437
+ return response;
438
+ }).catch(async (err) => {
439
+ for (const plugin of plugins) {
440
+ await plugin.onError?.(pluginContext, err);
441
+ }
442
+ throw err;
443
+ });
557
444
  const pendingEntry = {
558
445
  promise: actualPromise,
559
446
  request,
560
447
  controller
561
448
  };
562
449
  pendingRequests.push(pendingEntry);
563
- return actualPromise.finally(() => {
450
+ return actualPromise.finally(async () => {
451
+ for (const plugin of plugins) {
452
+ await plugin.onFinally?.(pluginContext);
453
+ }
564
454
  const index = pendingRequests.indexOf(pendingEntry);
565
455
  if (index > -1) {
566
456
  pendingRequests.splice(index, 1);
567
457
  }
568
- if (effectiveDedupe && dedupeKey && dedupeMap.get(dedupeKey)?.promise === actualPromise) {
569
- dedupeMap.delete(dedupeKey);
570
- stopDedupeSweeperIfIdle();
571
- }
572
458
  });
573
459
  };
574
460
  Object.defineProperty(client, "pendingRequests", {
@@ -584,18 +470,12 @@ function createClient(opts = {}) {
584
470
  enumerable: false,
585
471
  configurable: false
586
472
  });
587
- Object.defineProperty(client, "circuitOpen", {
588
- get() {
589
- return breaker ? breaker.open : false;
590
- },
591
- enumerable: true
592
- });
473
+ Object.defineProperties(client, extensionDescriptors);
593
474
  return client;
594
475
  }
595
476
 
596
477
  // src/index.ts
597
478
  init_error();
598
- var index_default = createClient;
599
479
  // Annotate the CommonJS export names for ESM import in node:
600
480
  0 && (module.exports = {
601
481
  AbortError,