@foundatiofx/fetchclient 1.0.1 → 1.1.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.
Files changed (106) hide show
  1. package/esm/mod.js +3 -1
  2. package/esm/src/CircuitBreaker.js +356 -0
  3. package/esm/src/CircuitBreakerMiddleware.js +167 -0
  4. package/esm/src/DefaultHelpers.js +23 -0
  5. package/esm/src/FetchClient.js +1 -1
  6. package/esm/src/FetchClientCache.js +85 -8
  7. package/esm/src/FetchClientProvider.js +58 -3
  8. package/esm/src/mocks/MockHistory.js +63 -0
  9. package/esm/src/mocks/MockRegistry.js +267 -0
  10. package/esm/src/mocks/MockResponseBuilder.js +88 -0
  11. package/esm/src/mocks/mod.js +24 -0
  12. package/esm/src/mocks/types.js +1 -0
  13. package/package.json +12 -2
  14. package/readme.md +117 -7
  15. package/script/mod.js +9 -1
  16. package/script/src/CircuitBreaker.js +361 -0
  17. package/script/src/CircuitBreakerMiddleware.js +174 -0
  18. package/script/src/DefaultHelpers.js +26 -0
  19. package/script/src/FetchClient.js +1 -1
  20. package/script/src/FetchClientCache.js +85 -8
  21. package/script/src/FetchClientProvider.js +58 -3
  22. package/script/src/mocks/MockHistory.js +67 -0
  23. package/script/src/mocks/MockRegistry.js +271 -0
  24. package/script/src/mocks/MockResponseBuilder.js +92 -0
  25. package/script/src/mocks/mod.js +29 -0
  26. package/script/src/mocks/types.js +2 -0
  27. package/types/deps/jsr.io/@std/assert/1.0.18/almost_equals.d.ts.map +1 -0
  28. package/types/deps/jsr.io/@std/assert/1.0.18/array_includes.d.ts.map +1 -0
  29. package/types/deps/jsr.io/@std/assert/1.0.18/assert.d.ts.map +1 -0
  30. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/assertion_error.d.ts.map +1 -1
  31. package/types/deps/jsr.io/@std/assert/1.0.18/equal.d.ts.map +1 -0
  32. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/equals.d.ts.map +1 -1
  33. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/exists.d.ts.map +1 -1
  34. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/fail.d.ts.map +1 -1
  35. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/false.d.ts.map +1 -1
  36. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/greater.d.ts.map +1 -1
  37. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/greater_or_equal.d.ts.map +1 -1
  38. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/instance_of.d.ts.map +1 -1
  39. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/is_error.d.ts.map +1 -1
  40. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/less.d.ts.map +1 -1
  41. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/less_or_equal.d.ts.map +1 -1
  42. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/match.d.ts.map +1 -1
  43. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/mod.d.ts.map +1 -1
  44. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/not_equals.d.ts.map +1 -1
  45. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/not_instance_of.d.ts.map +1 -1
  46. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/not_match.d.ts.map +1 -1
  47. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/not_strict_equals.d.ts.map +1 -1
  48. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/object_match.d.ts.map +1 -1
  49. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/rejects.d.ts.map +1 -1
  50. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/strict_equals.d.ts.map +1 -1
  51. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/string_includes.d.ts.map +1 -1
  52. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/throws.d.ts.map +1 -1
  53. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/unimplemented.d.ts.map +1 -1
  54. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/unreachable.d.ts.map +1 -1
  55. package/types/deps/jsr.io/@std/internal/1.0.12/build_message.d.ts.map +1 -0
  56. package/types/deps/jsr.io/@std/internal/{1.0.10 → 1.0.12}/diff.d.ts.map +1 -1
  57. package/types/deps/jsr.io/@std/internal/1.0.12/diff_str.d.ts.map +1 -0
  58. package/types/deps/jsr.io/@std/internal/{1.0.10 → 1.0.12}/format.d.ts.map +1 -1
  59. package/types/deps/jsr.io/@std/internal/{1.0.10 → 1.0.12}/styles.d.ts.map +1 -1
  60. package/types/deps/jsr.io/@std/internal/1.0.12/types.d.ts.map +1 -0
  61. package/types/mod.d.ts +3 -1
  62. package/types/mod.d.ts.map +1 -1
  63. package/types/src/CircuitBreaker.d.ts +154 -0
  64. package/types/src/CircuitBreaker.d.ts.map +1 -0
  65. package/types/src/CircuitBreakerMiddleware.d.ts +93 -0
  66. package/types/src/CircuitBreakerMiddleware.d.ts.map +1 -0
  67. package/types/src/DefaultHelpers.d.ts +19 -0
  68. package/types/src/DefaultHelpers.d.ts.map +1 -1
  69. package/types/src/FetchClient.d.ts.map +1 -1
  70. package/types/src/FetchClientCache.d.ts +26 -1
  71. package/types/src/FetchClientCache.d.ts.map +1 -1
  72. package/types/src/FetchClientProvider.d.ts +24 -0
  73. package/types/src/FetchClientProvider.d.ts.map +1 -1
  74. package/types/src/RequestOptions.d.ts +6 -1
  75. package/types/src/RequestOptions.d.ts.map +1 -1
  76. package/types/src/mocks/MockHistory.d.ts +22 -0
  77. package/types/src/mocks/MockHistory.d.ts.map +1 -0
  78. package/types/src/mocks/MockRegistry.d.ts +113 -0
  79. package/types/src/mocks/MockRegistry.d.ts.map +1 -0
  80. package/types/src/mocks/MockResponseBuilder.d.ts +60 -0
  81. package/types/src/mocks/MockResponseBuilder.d.ts.map +1 -0
  82. package/types/src/mocks/mod.d.ts +26 -0
  83. package/types/src/mocks/mod.d.ts.map +1 -0
  84. package/types/src/mocks/types.d.ts +47 -0
  85. package/types/src/mocks/types.d.ts.map +1 -0
  86. package/types/src/tests/Caching.test.d.ts.map +1 -0
  87. package/types/src/tests/CircuitBreaker.test.d.ts.map +1 -0
  88. package/types/src/tests/ErrorHandling.test.d.ts.map +1 -0
  89. package/types/src/tests/HttpMethods.test.d.ts.map +1 -0
  90. package/types/src/tests/Integration.test.d.ts.map +1 -0
  91. package/types/src/tests/JsonParsing.test.d.ts.map +1 -0
  92. package/types/src/tests/Middleware.test.d.ts.map +1 -0
  93. package/types/src/tests/MockRegistry.test.d.ts.map +1 -0
  94. package/types/src/tests/Provider.test.d.ts.map +1 -0
  95. package/types/src/tests/RateLimit.test.d.ts.map +1 -0
  96. package/types/src/tests/TimeoutAbort.test.d.ts.map +1 -0
  97. package/types/src/tests/UrlBuilding.test.d.ts.map +1 -0
  98. package/types/deps/jsr.io/@std/assert/1.0.14/almost_equals.d.ts.map +0 -1
  99. package/types/deps/jsr.io/@std/assert/1.0.14/array_includes.d.ts.map +0 -1
  100. package/types/deps/jsr.io/@std/assert/1.0.14/assert.d.ts.map +0 -1
  101. package/types/deps/jsr.io/@std/assert/1.0.14/equal.d.ts.map +0 -1
  102. package/types/deps/jsr.io/@std/internal/1.0.10/build_message.d.ts.map +0 -1
  103. package/types/deps/jsr.io/@std/internal/1.0.10/diff_str.d.ts.map +0 -1
  104. package/types/deps/jsr.io/@std/internal/1.0.10/types.d.ts.map +0 -1
  105. package/types/src/FetchClient.test.d.ts.map +0 -1
  106. package/types/src/RateLimit.test.d.ts.map +0 -1
package/esm/mod.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export { FetchClient } from "./src/FetchClient.js";
2
2
  export { ProblemDetails } from "./src/ProblemDetails.js";
3
- export { FetchClientCache } from "./src/FetchClientCache.js";
3
+ export { FetchClientCache, } from "./src/FetchClientCache.js";
4
4
  export { defaultInstance as defaultProviderInstance, FetchClientProvider, } from "./src/FetchClientProvider.js";
5
5
  export * from "./src/DefaultHelpers.js";
6
+ export { CircuitBreaker, groupByDomain as circuitBreakerGroupByDomain, } from "./src/CircuitBreaker.js";
7
+ export { CircuitBreakerMiddleware, CircuitOpenError, createCircuitBreakerMiddleware, createPerDomainCircuitBreakerMiddleware, } from "./src/CircuitBreakerMiddleware.js";
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Circuit breaker for preventing cascading failures.
3
+ *
4
+ * When a service starts failing (returning 5xx errors, timing out, etc.),
5
+ * the circuit breaker "opens" and blocks further requests for a period,
6
+ * allowing the service time to recover.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * const breaker = new CircuitBreaker({
11
+ * failureThreshold: 5, // Open after 5 failures
12
+ * openDurationMs: 30000, // Stay open for 30 seconds
13
+ * successThreshold: 2, // Close after 2 successes in HALF_OPEN
14
+ * });
15
+ *
16
+ * // Before making a request
17
+ * if (!breaker.isAllowed(url)) {
18
+ * // Circuit is open, don't make request
19
+ * return;
20
+ * }
21
+ *
22
+ * // After getting a response
23
+ * if (response.status >= 500) {
24
+ * breaker.recordFailure(url);
25
+ * } else {
26
+ * breaker.recordSuccess(url);
27
+ * }
28
+ * ```
29
+ */
30
+ export class CircuitBreaker {
31
+ #buckets = new Map();
32
+ #groupOptions = new Map();
33
+ #options;
34
+ #onOpen;
35
+ #onClose;
36
+ #onHalfOpen;
37
+ constructor(options) {
38
+ this.#options = {
39
+ failureThreshold: options?.failureThreshold ?? 5,
40
+ failureWindowMs: options?.failureWindowMs ?? 60000,
41
+ openDurationMs: options?.openDurationMs ?? 30000,
42
+ successThreshold: options?.successThreshold ?? 2,
43
+ halfOpenMaxAttempts: options?.halfOpenMaxAttempts ?? 1,
44
+ getGroupFunc: options?.getGroupFunc ?? (() => "global"),
45
+ onStateChange: options?.onStateChange,
46
+ };
47
+ this.#onOpen = options?.onOpen;
48
+ this.#onClose = options?.onClose;
49
+ this.#onHalfOpen = options?.onHalfOpen;
50
+ // Initialize per-group options
51
+ if (options?.groups) {
52
+ for (const [group, groupOpts] of Object.entries(options.groups)) {
53
+ this.#groupOptions.set(group, groupOpts);
54
+ }
55
+ }
56
+ }
57
+ /**
58
+ * Gets the effective options for a group.
59
+ */
60
+ #getOptions(group) {
61
+ const groupOpts = this.#groupOptions.get(group);
62
+ return {
63
+ failureThreshold: groupOpts?.failureThreshold ??
64
+ this.#options.failureThreshold,
65
+ failureWindowMs: groupOpts?.failureWindowMs ??
66
+ this.#options.failureWindowMs,
67
+ openDurationMs: groupOpts?.openDurationMs ?? this.#options.openDurationMs,
68
+ successThreshold: groupOpts?.successThreshold ??
69
+ this.#options.successThreshold,
70
+ halfOpenMaxAttempts: groupOpts?.halfOpenMaxAttempts ??
71
+ this.#options.halfOpenMaxAttempts,
72
+ onStateChange: groupOpts?.onStateChange ?? this.#options.onStateChange,
73
+ };
74
+ }
75
+ /**
76
+ * Gets or creates a bucket for the given group.
77
+ */
78
+ #getBucket(group) {
79
+ let bucket = this.#buckets.get(group);
80
+ if (!bucket) {
81
+ bucket = {
82
+ state: "CLOSED",
83
+ failures: [],
84
+ successCount: 0,
85
+ openedAt: null,
86
+ halfOpenAttempts: 0,
87
+ };
88
+ this.#buckets.set(group, bucket);
89
+ }
90
+ return bucket;
91
+ }
92
+ /**
93
+ * Transitions the circuit to a new state.
94
+ */
95
+ #transitionTo(group, bucket, newState) {
96
+ const oldState = bucket.state;
97
+ if (oldState === newState)
98
+ return;
99
+ bucket.state = newState;
100
+ // Reset state-specific counters
101
+ if (newState === "OPEN") {
102
+ bucket.openedAt = Date.now();
103
+ bucket.successCount = 0;
104
+ bucket.halfOpenAttempts = 0;
105
+ }
106
+ else if (newState === "HALF_OPEN") {
107
+ bucket.successCount = 0;
108
+ bucket.halfOpenAttempts = 0;
109
+ }
110
+ else if (newState === "CLOSED") {
111
+ bucket.failures = [];
112
+ bucket.openedAt = null;
113
+ bucket.successCount = 0;
114
+ bucket.halfOpenAttempts = 0;
115
+ }
116
+ // Trigger callbacks
117
+ const opts = this.#getOptions(group);
118
+ opts.onStateChange?.(oldState, newState);
119
+ if (newState === "OPEN") {
120
+ this.#onOpen?.(group);
121
+ }
122
+ else if (newState === "CLOSED") {
123
+ this.#onClose?.(group);
124
+ }
125
+ else if (newState === "HALF_OPEN") {
126
+ this.#onHalfOpen?.(group);
127
+ }
128
+ }
129
+ /**
130
+ * Cleans up old failures outside the time window.
131
+ */
132
+ #cleanupFailures(bucket, windowMs) {
133
+ const cutoff = Date.now() - windowMs;
134
+ bucket.failures = bucket.failures.filter((t) => t > cutoff);
135
+ }
136
+ /**
137
+ * Checks if a request to the given URL is allowed.
138
+ * Call this before making a request.
139
+ *
140
+ * @param url - The URL being requested
141
+ * @returns true if the request is allowed, false if circuit is open
142
+ */
143
+ isAllowed(url) {
144
+ const group = this.#options.getGroupFunc(url);
145
+ const bucket = this.#getBucket(group);
146
+ const opts = this.#getOptions(group);
147
+ switch (bucket.state) {
148
+ case "CLOSED":
149
+ return true;
150
+ case "OPEN": {
151
+ // Check if enough time has passed to try HALF_OPEN
152
+ const elapsed = Date.now() - (bucket.openedAt ?? 0);
153
+ if (elapsed >= opts.openDurationMs) {
154
+ this.#transitionTo(group, bucket, "HALF_OPEN");
155
+ // Fall through to HALF_OPEN logic
156
+ }
157
+ else {
158
+ return false;
159
+ }
160
+ }
161
+ // falls through
162
+ case "HALF_OPEN": {
163
+ // Allow limited requests in HALF_OPEN
164
+ if (bucket.halfOpenAttempts < opts.halfOpenMaxAttempts) {
165
+ bucket.halfOpenAttempts++;
166
+ return true;
167
+ }
168
+ return false;
169
+ }
170
+ }
171
+ }
172
+ /**
173
+ * Records a successful response.
174
+ * Call this after receiving a successful (non-failure) response.
175
+ *
176
+ * @param url - The URL that was requested
177
+ */
178
+ recordSuccess(url) {
179
+ const group = this.#options.getGroupFunc(url);
180
+ const bucket = this.#getBucket(group);
181
+ const opts = this.#getOptions(group);
182
+ switch (bucket.state) {
183
+ case "CLOSED":
184
+ // Success in CLOSED state - nothing special to do
185
+ // Optionally could reset failure count, but we use time-based cleanup
186
+ break;
187
+ case "HALF_OPEN":
188
+ // Decrement in-flight counter
189
+ bucket.halfOpenAttempts = Math.max(0, bucket.halfOpenAttempts - 1);
190
+ bucket.successCount++;
191
+ // Check if we've had enough successes to close
192
+ if (bucket.successCount >= opts.successThreshold) {
193
+ this.#transitionTo(group, bucket, "CLOSED");
194
+ }
195
+ break;
196
+ case "OPEN":
197
+ // Shouldn't happen - requests blocked in OPEN
198
+ break;
199
+ }
200
+ }
201
+ /**
202
+ * Records a failed response.
203
+ * Call this after receiving a failure response (5xx, timeout, network error).
204
+ *
205
+ * @param url - The URL that was requested
206
+ */
207
+ recordFailure(url) {
208
+ const group = this.#options.getGroupFunc(url);
209
+ const bucket = this.#getBucket(group);
210
+ const opts = this.#getOptions(group);
211
+ switch (bucket.state) {
212
+ case "CLOSED":
213
+ // Clean up old failures
214
+ this.#cleanupFailures(bucket, opts.failureWindowMs);
215
+ // Record new failure
216
+ bucket.failures.push(Date.now());
217
+ // Check if we've hit the threshold
218
+ if (bucket.failures.length >= opts.failureThreshold) {
219
+ this.#transitionTo(group, bucket, "OPEN");
220
+ }
221
+ break;
222
+ case "HALF_OPEN":
223
+ // Failure in HALF_OPEN - back to OPEN
224
+ bucket.halfOpenAttempts = Math.max(0, bucket.halfOpenAttempts - 1);
225
+ this.#transitionTo(group, bucket, "OPEN");
226
+ break;
227
+ case "OPEN":
228
+ // Shouldn't happen - requests blocked in OPEN
229
+ break;
230
+ }
231
+ }
232
+ /**
233
+ * Gets the current state of the circuit for a URL.
234
+ *
235
+ * @param url - The URL to check
236
+ * @returns The current circuit state
237
+ */
238
+ getState(url) {
239
+ const group = this.#options.getGroupFunc(url);
240
+ const bucket = this.#buckets.get(group);
241
+ if (!bucket)
242
+ return "CLOSED";
243
+ // Check for automatic transition to HALF_OPEN
244
+ if (bucket.state === "OPEN") {
245
+ const opts = this.#getOptions(group);
246
+ const elapsed = Date.now() - (bucket.openedAt ?? 0);
247
+ if (elapsed >= opts.openDurationMs) {
248
+ return "HALF_OPEN";
249
+ }
250
+ }
251
+ return bucket.state;
252
+ }
253
+ /**
254
+ * Gets the number of failures in the current window for a URL.
255
+ *
256
+ * @param url - The URL to check
257
+ * @returns The failure count
258
+ */
259
+ getFailureCount(url) {
260
+ const group = this.#options.getGroupFunc(url);
261
+ const bucket = this.#buckets.get(group);
262
+ if (!bucket)
263
+ return 0;
264
+ const opts = this.#getOptions(group);
265
+ this.#cleanupFailures(bucket, opts.failureWindowMs);
266
+ return bucket.failures.length;
267
+ }
268
+ /**
269
+ * Gets the time since the circuit opened for a URL.
270
+ *
271
+ * @param url - The URL to check
272
+ * @returns Time in ms since circuit opened, or null if not open
273
+ */
274
+ getTimeSinceOpen(url) {
275
+ const group = this.#options.getGroupFunc(url);
276
+ const bucket = this.#buckets.get(group);
277
+ if (!bucket || bucket.openedAt === null)
278
+ return null;
279
+ return Date.now() - bucket.openedAt;
280
+ }
281
+ /**
282
+ * Gets the time remaining before the circuit transitions to HALF_OPEN.
283
+ *
284
+ * @param url - The URL to check
285
+ * @returns Time in ms until HALF_OPEN, or null if not applicable
286
+ */
287
+ getTimeUntilHalfOpen(url) {
288
+ const group = this.#options.getGroupFunc(url);
289
+ const bucket = this.#buckets.get(group);
290
+ if (!bucket || bucket.state !== "OPEN" || bucket.openedAt === null) {
291
+ return null;
292
+ }
293
+ const opts = this.#getOptions(group);
294
+ const elapsed = Date.now() - bucket.openedAt;
295
+ const remaining = opts.openDurationMs - elapsed;
296
+ return remaining > 0 ? remaining : 0;
297
+ }
298
+ /**
299
+ * Manually resets (closes) the circuit for a URL or all circuits.
300
+ *
301
+ * @param url - Optional URL to reset. If omitted, resets all circuits.
302
+ */
303
+ reset(url) {
304
+ if (url !== undefined) {
305
+ const group = this.#options.getGroupFunc(url);
306
+ const bucket = this.#buckets.get(group);
307
+ if (bucket) {
308
+ this.#transitionTo(group, bucket, "CLOSED");
309
+ }
310
+ }
311
+ else {
312
+ // Reset all
313
+ for (const [group, bucket] of this.#buckets) {
314
+ this.#transitionTo(group, bucket, "CLOSED");
315
+ }
316
+ }
317
+ }
318
+ /**
319
+ * Manually trips (opens) the circuit for a URL.
320
+ *
321
+ * @param url - The URL to trip the circuit for
322
+ */
323
+ trip(url) {
324
+ const group = this.#options.getGroupFunc(url);
325
+ const bucket = this.#getBucket(group);
326
+ this.#transitionTo(group, bucket, "OPEN");
327
+ }
328
+ /**
329
+ * Sets options for a specific group.
330
+ *
331
+ * @param group - The group name
332
+ * @param options - The options to set
333
+ */
334
+ setGroupOptions(group, options) {
335
+ this.#groupOptions.set(group, options);
336
+ }
337
+ }
338
+ /**
339
+ * Groups URLs by their domain (hostname).
340
+ * Useful for per-domain circuit breakers.
341
+ *
342
+ * @example
343
+ * ```typescript
344
+ * const breaker = new CircuitBreaker({
345
+ * getGroupFunc: groupByDomain,
346
+ * });
347
+ * ```
348
+ */
349
+ export function groupByDomain(url) {
350
+ try {
351
+ return new URL(url).hostname;
352
+ }
353
+ catch {
354
+ return "unknown";
355
+ }
356
+ }
@@ -0,0 +1,167 @@
1
+ import { CircuitBreaker, groupByDomain, } from "./CircuitBreaker.js";
2
+ import { ProblemDetails } from "./ProblemDetails.js";
3
+ /**
4
+ * Error thrown when a request is blocked due to an open circuit.
5
+ */
6
+ export class CircuitOpenError extends Error {
7
+ /** The group whose circuit is open */
8
+ group;
9
+ /** The current circuit state */
10
+ state;
11
+ /** When the circuit was opened (timestamp) */
12
+ openedAt;
13
+ /** Suggested retry time in seconds */
14
+ retryAfter;
15
+ constructor(group, state, openedAt, retryAfter, message) {
16
+ super(message ?? `Circuit breaker is open for ${group}`);
17
+ this.name = "CircuitOpenError";
18
+ this.group = group;
19
+ this.state = state;
20
+ this.openedAt = openedAt;
21
+ this.retryAfter = retryAfter;
22
+ }
23
+ }
24
+ /**
25
+ * Default function to determine if a response is a failure.
26
+ * Returns true for 5xx server errors and 429 rate limit responses.
27
+ */
28
+ function defaultIsFailure(response) {
29
+ return response.status >= 500 || response.status === 429;
30
+ }
31
+ /**
32
+ * Middleware that implements the circuit breaker pattern.
33
+ *
34
+ * When a service starts failing (5xx errors, timeouts, network errors),
35
+ * the circuit breaker opens and blocks further requests for a period,
36
+ * returning 503 Service Unavailable immediately without hitting the API.
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * const middleware = new CircuitBreakerMiddleware({
41
+ * failureThreshold: 5,
42
+ * openDurationMs: 30000,
43
+ * });
44
+ *
45
+ * provider.useMiddleware(middleware.middleware());
46
+ * ```
47
+ */
48
+ export class CircuitBreakerMiddleware {
49
+ #circuitBreaker;
50
+ #throwOnOpen;
51
+ #errorMessage;
52
+ #isFailure;
53
+ #getGroupFunc;
54
+ #openDurationMs;
55
+ constructor(options) {
56
+ this.#circuitBreaker = new CircuitBreaker(options);
57
+ this.#throwOnOpen = options?.throwOnOpen ?? false;
58
+ this.#errorMessage = options?.errorMessage;
59
+ this.#isFailure = options?.isFailure ?? defaultIsFailure;
60
+ this.#getGroupFunc = options?.getGroupFunc ?? (() => "global");
61
+ this.#openDurationMs = options?.openDurationMs ?? 30000;
62
+ }
63
+ /**
64
+ * Gets the underlying circuit breaker instance.
65
+ */
66
+ get circuitBreaker() {
67
+ return this.#circuitBreaker;
68
+ }
69
+ /**
70
+ * Creates the middleware function for use with FetchClient.
71
+ *
72
+ * @returns The middleware function
73
+ */
74
+ middleware() {
75
+ return async (ctx, next) => {
76
+ const url = ctx.request.url;
77
+ const group = this.#getGroupFunc(url);
78
+ // PRE-REQUEST: Check if circuit allows the request
79
+ if (!this.#circuitBreaker.isAllowed(url)) {
80
+ const timeSinceOpen = this.#circuitBreaker.getTimeSinceOpen(url) ?? 0;
81
+ const retryAfterMs = Math.max(0, this.#openDurationMs - timeSinceOpen);
82
+ const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
83
+ if (this.#throwOnOpen) {
84
+ throw new CircuitOpenError(group, this.#circuitBreaker.getState(url), Date.now() - timeSinceOpen, retryAfterSeconds, this.#errorMessage);
85
+ }
86
+ // Return synthetic 503 response
87
+ const problem = new ProblemDetails();
88
+ problem.status = 503;
89
+ problem.title = "Service Unavailable";
90
+ problem.detail = this.#errorMessage ??
91
+ `Circuit breaker is open for ${group}. Service may be experiencing issues.`;
92
+ const headers = new Headers({
93
+ "Content-Type": "application/problem+json",
94
+ "Retry-After": String(retryAfterSeconds),
95
+ });
96
+ const response = new Response(JSON.stringify(problem), {
97
+ status: 503,
98
+ statusText: "Service Unavailable",
99
+ headers,
100
+ });
101
+ // Attach problem details like FetchClient does
102
+ Object.assign(response, { problem, data: null });
103
+ ctx.response = response;
104
+ return;
105
+ }
106
+ // EXECUTE REQUEST
107
+ let isNetworkError = false;
108
+ try {
109
+ await next();
110
+ }
111
+ catch (error) {
112
+ // Network errors count as failures
113
+ isNetworkError = true;
114
+ this.#circuitBreaker.recordFailure(url);
115
+ throw error;
116
+ }
117
+ // POST-RESPONSE: Record result
118
+ if (!isNetworkError && ctx.response) {
119
+ if (this.#isFailure(ctx.response)) {
120
+ this.#circuitBreaker.recordFailure(url);
121
+ }
122
+ else {
123
+ this.#circuitBreaker.recordSuccess(url);
124
+ }
125
+ }
126
+ };
127
+ }
128
+ }
129
+ /**
130
+ * Creates a circuit breaker middleware with the given options.
131
+ *
132
+ * @param options - Circuit breaker configuration
133
+ * @returns The middleware function
134
+ *
135
+ * @example
136
+ * ```typescript
137
+ * provider.useMiddleware(createCircuitBreakerMiddleware({
138
+ * failureThreshold: 5,
139
+ * openDurationMs: 30000,
140
+ * }));
141
+ * ```
142
+ */
143
+ export function createCircuitBreakerMiddleware(options) {
144
+ const middleware = new CircuitBreakerMiddleware(options);
145
+ return middleware.middleware();
146
+ }
147
+ /**
148
+ * Creates a per-domain circuit breaker middleware.
149
+ * Each domain gets its own circuit breaker.
150
+ *
151
+ * @param options - Circuit breaker configuration
152
+ * @returns The middleware function
153
+ *
154
+ * @example
155
+ * ```typescript
156
+ * provider.useMiddleware(createPerDomainCircuitBreakerMiddleware({
157
+ * failureThreshold: 5,
158
+ * openDurationMs: 30000,
159
+ * }));
160
+ * ```
161
+ */
162
+ export function createPerDomainCircuitBreakerMiddleware(options) {
163
+ return createCircuitBreakerMiddleware({
164
+ ...options,
165
+ getGroupFunc: groupByDomain,
166
+ });
167
+ }
@@ -73,6 +73,13 @@ export function getCurrentProvider() {
73
73
  }
74
74
  return getCurrentProviderFunc() ?? defaultProvider;
75
75
  }
76
+ /**
77
+ * Gets the cache from the current provider.
78
+ * @returns The FetchClientCache instance.
79
+ */
80
+ export function getCache() {
81
+ return getCurrentProvider().cache;
82
+ }
76
83
  /**
77
84
  * Sets the function that retrieves the current FetchClientProvider using whatever scoping mechanism is available.
78
85
  * @param getProviderFunc - The function that retrieves the current FetchClientProvider.
@@ -130,3 +137,19 @@ export function useRateLimit(options) {
130
137
  export function usePerDomainRateLimit(options) {
131
138
  getCurrentProvider().usePerDomainRateLimit(options);
132
139
  }
140
+ /**
141
+ * Enables circuit breaker for any FetchClient instances created by the current provider.
142
+ * The circuit breaker monitors failures and blocks requests when a service is failing.
143
+ * @param options - The circuit breaker configuration options.
144
+ */
145
+ export function useCircuitBreaker(options) {
146
+ getCurrentProvider().useCircuitBreaker(options);
147
+ }
148
+ /**
149
+ * Enables per-domain circuit breaker for any FetchClient instances created by the current provider.
150
+ * Each domain gets its own circuit breaker, so failures on one domain don't affect others.
151
+ * @param options - The circuit breaker configuration options.
152
+ */
153
+ export function usePerDomainCircuitBreaker(options) {
154
+ getCurrentProvider().usePerDomainCircuitBreaker(options);
155
+ }
@@ -295,7 +295,7 @@ export class FetchClient {
295
295
  links: parseLinkHeader(response.headers.get("Link")) || {},
296
296
  };
297
297
  if (getOptions?.cacheKey) {
298
- this.cache.set(getOptions.cacheKey, ctx.response, getOptions.cacheDuration);
298
+ this.cache.set(getOptions.cacheKey, ctx.response, getOptions.cacheDuration, getOptions.cacheTags);
299
299
  }
300
300
  }
301
301
  catch (error) {