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