@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foundatiofx/fetchclient",
3
- "version": "1.0.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.1.11",
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
  [![Discord](https://img.shields.io/discord/715744504891703319)](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 handling.
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
- type Products = { products: Array<{ id: number; name: string }> };
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
- const { data } = await client.getJSON<Products>(
27
- `https://dummyjson.com/products/search?q=iphone&limit=10`,
28
- );
131
+ mocks.install(client);
29
132
 
30
- console.log(data?.products.length);
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, Usage Examples, Contributing
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
+ }