@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foundatiofx/fetchclient",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "A typed JSON fetch client with middleware support for Deno, Node and the browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Fetch",
|
|
@@ -35,6 +35,16 @@
|
|
|
35
35
|
"types": "./types/mod.d.ts",
|
|
36
36
|
"default": "./script/mod.js"
|
|
37
37
|
}
|
|
38
|
+
},
|
|
39
|
+
"./mocks": {
|
|
40
|
+
"import": {
|
|
41
|
+
"types": "./types/src/mocks/mod.d.ts",
|
|
42
|
+
"default": "./esm/src/mocks/mod.js"
|
|
43
|
+
},
|
|
44
|
+
"require": {
|
|
45
|
+
"types": "./types/src/mocks/mod.d.ts",
|
|
46
|
+
"default": "./script/src/mocks/mod.js"
|
|
47
|
+
}
|
|
38
48
|
}
|
|
39
49
|
},
|
|
40
50
|
"scripts": {
|
|
@@ -43,7 +53,7 @@
|
|
|
43
53
|
"devDependencies": {
|
|
44
54
|
"@types/node": "^20.9.0",
|
|
45
55
|
"picocolors": "^1.0.0",
|
|
46
|
-
"zod": "^4.
|
|
56
|
+
"zod": "^4.3.6",
|
|
47
57
|
"@deno/shim-deno": "~0.18.0"
|
|
48
58
|
},
|
|
49
59
|
"_generatedBy": "dnt@dev"
|
package/readme.md
CHANGED
|
@@ -7,7 +7,21 @@
|
|
|
7
7
|
[](https://discord.gg/6HxgFCx)
|
|
8
8
|
|
|
9
9
|
FetchClient is a tiny, typed wrapper around `fetch` with JSON helpers, caching,
|
|
10
|
-
middleware, rate limiting, timeouts, and friendly error
|
|
10
|
+
middleware, rate limiting, circuit breaker, timeouts, and friendly error
|
|
11
|
+
handling.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Typed JSON helpers** - `getJSON`, `postJSON`, `putJSON`, `patchJSON`,
|
|
16
|
+
`deleteJSON`
|
|
17
|
+
- **Two API styles** - Functional or class-based - your choice
|
|
18
|
+
- **Response caching** - TTL-based caching with tags for grouped invalidation
|
|
19
|
+
- **Middleware** - Intercept requests/responses for logging, auth, transforms
|
|
20
|
+
- **Rate limiting** - Per-domain rate limits with automatic header detection
|
|
21
|
+
- **Circuit breaker** - Prevent cascading failures when services go down
|
|
22
|
+
- **Timeouts** - Request timeouts with AbortSignal support
|
|
23
|
+
- **Error handling** - RFC 7807 Problem Details support
|
|
24
|
+
- **Testing** - MockRegistry for mocking HTTP in tests
|
|
11
25
|
|
|
12
26
|
## Install
|
|
13
27
|
|
|
@@ -17,23 +31,119 @@ npm install @foundatiofx/fetchclient
|
|
|
17
31
|
|
|
18
32
|
## Quick Example
|
|
19
33
|
|
|
34
|
+
FetchClient works two ways - pick whichever style you prefer:
|
|
35
|
+
|
|
36
|
+
### Functional API
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { getJSON, postJSON, setBaseUrl } from "@foundatiofx/fetchclient";
|
|
40
|
+
|
|
41
|
+
setBaseUrl("https://api.example.com");
|
|
42
|
+
|
|
43
|
+
const { data: users } = await getJSON<User[]>("/users");
|
|
44
|
+
const { data: created } = await postJSON<User>("/users", { name: "Alice" });
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or use `getFetchClient()` to avoid multiple imports:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { getFetchClient, setBaseUrl } from "@foundatiofx/fetchclient";
|
|
51
|
+
|
|
52
|
+
setBaseUrl("https://api.example.com");
|
|
53
|
+
|
|
54
|
+
const client = getFetchClient();
|
|
55
|
+
const { data: users } = await client.getJSON<User[]>("/users");
|
|
56
|
+
const { data: created } = await client.postJSON<User>("/users", {
|
|
57
|
+
name: "Alice",
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Class-Based API
|
|
62
|
+
|
|
20
63
|
```ts
|
|
21
64
|
import { FetchClient } from "@foundatiofx/fetchclient";
|
|
22
65
|
|
|
23
|
-
|
|
66
|
+
const client = new FetchClient({ baseUrl: "https://api.example.com" });
|
|
67
|
+
const { data } = await client.getJSON<User[]>("/users");
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Caching
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const response = await client.getJSON<User>("/api/users/1", {
|
|
74
|
+
cacheKey: ["users", "1"],
|
|
75
|
+
cacheDuration: 60000, // 1 minute
|
|
76
|
+
cacheTags: ["users"],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Invalidate by tag
|
|
80
|
+
client.cache.deleteByTag("users");
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Middleware
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { useMiddleware } from "@foundatiofx/fetchclient";
|
|
87
|
+
|
|
88
|
+
useMiddleware(async (ctx, next) => {
|
|
89
|
+
console.log("Request:", ctx.request.url);
|
|
90
|
+
await next();
|
|
91
|
+
console.log("Response:", ctx.response?.status);
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Rate Limiting
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { usePerDomainRateLimit } from "@foundatiofx/fetchclient";
|
|
99
|
+
|
|
100
|
+
usePerDomainRateLimit({
|
|
101
|
+
maxRequests: 100,
|
|
102
|
+
windowSeconds: 60,
|
|
103
|
+
updateFromHeaders: true, // Respect API rate limit headers
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Circuit Breaker
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { useCircuitBreaker } from "@foundatiofx/fetchclient";
|
|
111
|
+
|
|
112
|
+
useCircuitBreaker({
|
|
113
|
+
failureThreshold: 5,
|
|
114
|
+
openDurationMs: 30000,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// When API fails repeatedly, circuit opens
|
|
118
|
+
// Requests return 503 immediately without hitting the API
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Testing
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { FetchClientProvider } from "@foundatiofx/fetchclient";
|
|
125
|
+
import { MockRegistry } from "@foundatiofx/fetchclient/mocks";
|
|
126
|
+
|
|
127
|
+
const mocks = new MockRegistry();
|
|
128
|
+
mocks.onGet("/api/users").reply(200, [{ id: 1, name: "Alice" }]);
|
|
24
129
|
|
|
25
130
|
const client = new FetchClient();
|
|
26
|
-
|
|
27
|
-
`https://dummyjson.com/products/search?q=iphone&limit=10`,
|
|
28
|
-
);
|
|
131
|
+
mocks.install(client);
|
|
29
132
|
|
|
30
|
-
|
|
133
|
+
const { data } = await client.getJSON("/api/users");
|
|
134
|
+
// data = [{ id: 1, name: "Alice" }]
|
|
31
135
|
```
|
|
32
136
|
|
|
33
137
|
## Documentation
|
|
34
138
|
|
|
35
139
|
- Guide & Examples: <https://fetchclient.foundatio.dev>
|
|
36
|
-
- Getting Started
|
|
140
|
+
- [Getting Started](https://fetchclient.foundatio.dev/guide/getting-started)
|
|
141
|
+
- [Caching](https://fetchclient.foundatio.dev/guide/caching)
|
|
142
|
+
- [Middleware](https://fetchclient.foundatio.dev/guide/middleware)
|
|
143
|
+
- [Rate Limiting](https://fetchclient.foundatio.dev/guide/rate-limiting)
|
|
144
|
+
- [Circuit Breaker](https://fetchclient.foundatio.dev/guide/circuit-breaker)
|
|
145
|
+
- [Error Handling](https://fetchclient.foundatio.dev/guide/error-handling)
|
|
146
|
+
- [Testing](https://fetchclient.foundatio.dev/guide/testing)
|
|
37
147
|
- API Reference: <https://jsr.io/@foundatiofx/fetchclient/doc>
|
|
38
148
|
|
|
39
149
|
---
|
package/script/mod.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.FetchClientProvider = exports.defaultProviderInstance = exports.FetchClientCache = exports.ProblemDetails = exports.FetchClient = void 0;
|
|
17
|
+
exports.createPerDomainCircuitBreakerMiddleware = exports.createCircuitBreakerMiddleware = exports.CircuitOpenError = exports.CircuitBreakerMiddleware = exports.circuitBreakerGroupByDomain = exports.CircuitBreaker = exports.FetchClientProvider = exports.defaultProviderInstance = exports.FetchClientCache = exports.ProblemDetails = exports.FetchClient = void 0;
|
|
18
18
|
var FetchClient_js_1 = require("./src/FetchClient.js");
|
|
19
19
|
Object.defineProperty(exports, "FetchClient", { enumerable: true, get: function () { return FetchClient_js_1.FetchClient; } });
|
|
20
20
|
var ProblemDetails_js_1 = require("./src/ProblemDetails.js");
|
|
@@ -25,3 +25,11 @@ var FetchClientProvider_js_1 = require("./src/FetchClientProvider.js");
|
|
|
25
25
|
Object.defineProperty(exports, "defaultProviderInstance", { enumerable: true, get: function () { return FetchClientProvider_js_1.defaultInstance; } });
|
|
26
26
|
Object.defineProperty(exports, "FetchClientProvider", { enumerable: true, get: function () { return FetchClientProvider_js_1.FetchClientProvider; } });
|
|
27
27
|
__exportStar(require("./src/DefaultHelpers.js"), exports);
|
|
28
|
+
var CircuitBreaker_js_1 = require("./src/CircuitBreaker.js");
|
|
29
|
+
Object.defineProperty(exports, "CircuitBreaker", { enumerable: true, get: function () { return CircuitBreaker_js_1.CircuitBreaker; } });
|
|
30
|
+
Object.defineProperty(exports, "circuitBreakerGroupByDomain", { enumerable: true, get: function () { return CircuitBreaker_js_1.groupByDomain; } });
|
|
31
|
+
var CircuitBreakerMiddleware_js_1 = require("./src/CircuitBreakerMiddleware.js");
|
|
32
|
+
Object.defineProperty(exports, "CircuitBreakerMiddleware", { enumerable: true, get: function () { return CircuitBreakerMiddleware_js_1.CircuitBreakerMiddleware; } });
|
|
33
|
+
Object.defineProperty(exports, "CircuitOpenError", { enumerable: true, get: function () { return CircuitBreakerMiddleware_js_1.CircuitOpenError; } });
|
|
34
|
+
Object.defineProperty(exports, "createCircuitBreakerMiddleware", { enumerable: true, get: function () { return CircuitBreakerMiddleware_js_1.createCircuitBreakerMiddleware; } });
|
|
35
|
+
Object.defineProperty(exports, "createPerDomainCircuitBreakerMiddleware", { enumerable: true, get: function () { return CircuitBreakerMiddleware_js_1.createPerDomainCircuitBreakerMiddleware; } });
|
|
@@ -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
|
+
}
|