@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.
- package/esm/mod.js +3 -1
- package/esm/src/CircuitBreaker.js +356 -0
- package/esm/src/CircuitBreakerMiddleware.js +167 -0
- package/esm/src/DefaultHelpers.js +23 -0
- package/esm/src/FetchClient.js +1 -1
- package/esm/src/FetchClientCache.js +85 -8
- package/esm/src/FetchClientProvider.js +58 -3
- package/esm/src/mocks/MockHistory.js +63 -0
- package/esm/src/mocks/MockRegistry.js +267 -0
- package/esm/src/mocks/MockResponseBuilder.js +88 -0
- package/esm/src/mocks/mod.js +24 -0
- package/esm/src/mocks/types.js +1 -0
- package/package.json +12 -2
- package/readme.md +117 -7
- package/script/mod.js +9 -1
- package/script/src/CircuitBreaker.js +361 -0
- package/script/src/CircuitBreakerMiddleware.js +174 -0
- package/script/src/DefaultHelpers.js +26 -0
- package/script/src/FetchClient.js +1 -1
- package/script/src/FetchClientCache.js +85 -8
- package/script/src/FetchClientProvider.js +58 -3
- package/script/src/mocks/MockHistory.js +67 -0
- package/script/src/mocks/MockRegistry.js +271 -0
- package/script/src/mocks/MockResponseBuilder.js +92 -0
- package/script/src/mocks/mod.js +29 -0
- package/script/src/mocks/types.js +2 -0
- package/types/deps/jsr.io/@std/assert/1.0.18/almost_equals.d.ts.map +1 -0
- package/types/deps/jsr.io/@std/assert/1.0.18/array_includes.d.ts.map +1 -0
- package/types/deps/jsr.io/@std/assert/1.0.18/assert.d.ts.map +1 -0
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/assertion_error.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/1.0.18/equal.d.ts.map +1 -0
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/equals.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/exists.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/fail.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/false.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/greater.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/greater_or_equal.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/instance_of.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/is_error.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/less.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/less_or_equal.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/match.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/mod.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/not_equals.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/not_instance_of.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/not_match.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/not_strict_equals.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/object_match.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/rejects.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/strict_equals.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/string_includes.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/throws.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/unimplemented.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/unreachable.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/internal/1.0.12/build_message.d.ts.map +1 -0
- package/types/deps/jsr.io/@std/internal/{1.0.10 → 1.0.12}/diff.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/internal/1.0.12/diff_str.d.ts.map +1 -0
- package/types/deps/jsr.io/@std/internal/{1.0.10 → 1.0.12}/format.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/internal/{1.0.10 → 1.0.12}/styles.d.ts.map +1 -1
- package/types/deps/jsr.io/@std/internal/1.0.12/types.d.ts.map +1 -0
- package/types/mod.d.ts +3 -1
- package/types/mod.d.ts.map +1 -1
- package/types/src/CircuitBreaker.d.ts +154 -0
- package/types/src/CircuitBreaker.d.ts.map +1 -0
- package/types/src/CircuitBreakerMiddleware.d.ts +93 -0
- package/types/src/CircuitBreakerMiddleware.d.ts.map +1 -0
- package/types/src/DefaultHelpers.d.ts +19 -0
- package/types/src/DefaultHelpers.d.ts.map +1 -1
- package/types/src/FetchClient.d.ts.map +1 -1
- package/types/src/FetchClientCache.d.ts +26 -1
- package/types/src/FetchClientCache.d.ts.map +1 -1
- package/types/src/FetchClientProvider.d.ts +24 -0
- package/types/src/FetchClientProvider.d.ts.map +1 -1
- package/types/src/RequestOptions.d.ts +6 -1
- package/types/src/RequestOptions.d.ts.map +1 -1
- package/types/src/mocks/MockHistory.d.ts +22 -0
- package/types/src/mocks/MockHistory.d.ts.map +1 -0
- package/types/src/mocks/MockRegistry.d.ts +113 -0
- package/types/src/mocks/MockRegistry.d.ts.map +1 -0
- package/types/src/mocks/MockResponseBuilder.d.ts +60 -0
- package/types/src/mocks/MockResponseBuilder.d.ts.map +1 -0
- package/types/src/mocks/mod.d.ts +26 -0
- package/types/src/mocks/mod.d.ts.map +1 -0
- package/types/src/mocks/types.d.ts +47 -0
- package/types/src/mocks/types.d.ts.map +1 -0
- package/types/src/tests/Caching.test.d.ts.map +1 -0
- package/types/src/tests/CircuitBreaker.test.d.ts.map +1 -0
- package/types/src/tests/ErrorHandling.test.d.ts.map +1 -0
- package/types/src/tests/HttpMethods.test.d.ts.map +1 -0
- package/types/src/tests/Integration.test.d.ts.map +1 -0
- package/types/src/tests/JsonParsing.test.d.ts.map +1 -0
- package/types/src/tests/Middleware.test.d.ts.map +1 -0
- package/types/src/tests/MockRegistry.test.d.ts.map +1 -0
- package/types/src/tests/Provider.test.d.ts.map +1 -0
- package/types/src/tests/RateLimit.test.d.ts.map +1 -0
- package/types/src/tests/TimeoutAbort.test.d.ts.map +1 -0
- package/types/src/tests/UrlBuilding.test.d.ts.map +1 -0
- package/types/deps/jsr.io/@std/assert/1.0.14/almost_equals.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/assert/1.0.14/array_includes.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/assert/1.0.14/assert.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/assert/1.0.14/equal.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/internal/1.0.10/build_message.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/internal/1.0.10/diff_str.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/internal/1.0.10/types.d.ts.map +0 -1
- package/types/src/FetchClient.test.d.ts.map +0 -1
- 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
|
+
}
|
package/esm/src/FetchClient.js
CHANGED
|
@@ -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) {
|