@vacbo/opencode-anthropic-fix 0.0.44 → 0.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 (61) hide show
  1. package/README.md +19 -0
  2. package/dist/bun-proxy.mjs +282 -55
  3. package/dist/opencode-anthropic-auth-cli.mjs +194 -55
  4. package/dist/opencode-anthropic-auth-plugin.js +1816 -594
  5. package/package.json +1 -1
  6. package/src/__tests__/billing-edge-cases.test.ts +84 -0
  7. package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
  8. package/src/__tests__/debug-gating.test.ts +76 -0
  9. package/src/__tests__/decomposition-smoke.test.ts +92 -0
  10. package/src/__tests__/fingerprint-regression.test.ts +1 -1
  11. package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
  12. package/src/__tests__/helpers/conversation-history.ts +376 -0
  13. package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
  14. package/src/__tests__/helpers/deferred.ts +122 -0
  15. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
  16. package/src/__tests__/helpers/in-memory-storage.ts +152 -0
  17. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
  18. package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
  19. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
  20. package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
  21. package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
  22. package/src/__tests__/helpers/sse.ts +288 -0
  23. package/src/__tests__/index.parallel.test.ts +711 -0
  24. package/src/__tests__/sanitization-regex.test.ts +65 -0
  25. package/src/__tests__/state-bounds.test.ts +110 -0
  26. package/src/account-identity.test.ts +213 -0
  27. package/src/account-identity.ts +108 -0
  28. package/src/accounts.dedup.test.ts +696 -0
  29. package/src/accounts.test.ts +2 -1
  30. package/src/accounts.ts +485 -191
  31. package/src/bun-fetch.test.ts +379 -0
  32. package/src/bun-fetch.ts +447 -174
  33. package/src/bun-proxy.ts +289 -57
  34. package/src/circuit-breaker.test.ts +274 -0
  35. package/src/circuit-breaker.ts +235 -0
  36. package/src/cli.test.ts +1 -0
  37. package/src/cli.ts +37 -18
  38. package/src/commands/router.ts +25 -5
  39. package/src/env.ts +1 -0
  40. package/src/headers/billing.ts +31 -13
  41. package/src/index.ts +224 -247
  42. package/src/oauth.ts +7 -1
  43. package/src/parent-pid-watcher.test.ts +219 -0
  44. package/src/parent-pid-watcher.ts +99 -0
  45. package/src/plugin-helpers.ts +112 -0
  46. package/src/refresh-helpers.ts +169 -0
  47. package/src/refresh-lock.test.ts +36 -9
  48. package/src/refresh-lock.ts +2 -2
  49. package/src/request/body.history.test.ts +398 -0
  50. package/src/request/body.ts +200 -13
  51. package/src/request/metadata.ts +6 -2
  52. package/src/response/index.ts +1 -1
  53. package/src/response/mcp.ts +60 -31
  54. package/src/response/streaming.test.ts +382 -0
  55. package/src/response/streaming.ts +403 -76
  56. package/src/storage.test.ts +127 -104
  57. package/src/storage.ts +152 -62
  58. package/src/system-prompt/builder.ts +33 -3
  59. package/src/system-prompt/sanitize.ts +12 -2
  60. package/src/token-refresh.test.ts +84 -1
  61. package/src/token-refresh.ts +14 -8
package/src/bun-proxy.ts CHANGED
@@ -1,71 +1,303 @@
1
- // Standalone Bun TLS proxy — run with: bun dist/bun-proxy.mjs [port]
2
- // Forwards requests using Bun's native fetch (BoringSSL TLS fingerprint).
1
+ import { resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
3
 
4
- const PORT = parseInt(process.argv[2] || "48372", 10);
4
+ import { ParentPidWatcher } from "./parent-pid-watcher.js";
5
5
 
6
- const server = Bun.serve({
7
- port: PORT,
8
- async fetch(req: Request): Promise<Response> {
9
- if (new URL(req.url).pathname === "/__health") {
10
- return new Response("ok");
6
+ const DEFAULT_ALLOWED_HOSTS = ["api.anthropic.com", "platform.claude.com"];
7
+ const DEFAULT_REQUEST_TIMEOUT_MS = 600_000;
8
+ const DEFAULT_PARENT_EXIT_CODE = 1;
9
+ const DEFAULT_PARENT_POLL_INTERVAL_MS = 5_000;
10
+ const HEALTH_PATH = "/__health";
11
+ const DEBUG_ENABLED = process.env.OPENCODE_ANTHROPIC_DEBUG === "1";
12
+
13
+ interface ProxyRequestHandlerOptions {
14
+ fetchImpl: typeof fetch;
15
+ allowHosts?: string[];
16
+ requestTimeoutMs?: number;
17
+ }
18
+
19
+ interface ProxyProcessRuntimeOptions {
20
+ argv?: string[];
21
+ exit?: (code?: number) => void;
22
+ parentWatcherFactory?: ParentWatcherFactory;
23
+ }
24
+
25
+ interface ParentWatcher {
26
+ start(): void;
27
+ stop(): void;
28
+ }
29
+
30
+ interface ParentWatcherFactoryOptions {
31
+ parentPid: number;
32
+ onParentExit: (exitCode?: number) => void;
33
+ pollIntervalMs?: number;
34
+ exitCode?: number;
35
+ }
36
+
37
+ type ParentWatcherFactory = (options: ParentWatcherFactoryOptions) => ParentWatcher;
38
+
39
+ type RequestInitWithDuplex = RequestInit & {
40
+ duplex?: "half";
41
+ };
42
+
43
+ interface AbortContext {
44
+ timeoutSignal: AbortSignal;
45
+ cancelTimeout(): void;
46
+ }
47
+
48
+ function isMainModule(argv: string[] = process.argv): boolean {
49
+ return Boolean(argv[1]) && resolve(argv[1]) === fileURLToPath(import.meta.url);
50
+ }
51
+
52
+ function parseInteger(value: string | undefined): number | null {
53
+ const parsed = Number.parseInt(value ?? "", 10);
54
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
55
+ }
56
+
57
+ function parseParentPid(argv: string[]): number | null {
58
+ const inlineValue = argv
59
+ .map((argument) => argument.match(/^--parent-pid=(\d+)$/)?.[1] ?? null)
60
+ .find((value) => value !== null);
61
+
62
+ if (inlineValue) {
63
+ return parseInteger(inlineValue);
64
+ }
65
+
66
+ const flagIndex = argv.indexOf("--parent-pid");
67
+ return flagIndex >= 0 ? parseInteger(argv[flagIndex + 1]) : null;
68
+ }
69
+
70
+ function createNoopWatcher(): ParentWatcher {
71
+ return {
72
+ start(): void {},
73
+ stop(): void {},
74
+ };
75
+ }
76
+
77
+ function createDefaultParentWatcherFactory(): ParentWatcherFactory {
78
+ return ({ parentPid, onParentExit, pollIntervalMs, exitCode }): ParentWatcher =>
79
+ new ParentPidWatcher({
80
+ parentPid,
81
+ pollIntervalMs,
82
+ onParentGone: () => {
83
+ onParentExit(exitCode);
84
+ },
85
+ });
86
+ }
87
+
88
+ function sanitizeForwardHeaders(source: Headers): Headers {
89
+ const headers = new Headers(source);
90
+ ["x-proxy-url", "host", "connection", "content-length"].forEach((headerName) => {
91
+ headers.delete(headerName);
92
+ });
93
+ return headers;
94
+ }
95
+
96
+ function copyResponseHeaders(source: Headers): Headers {
97
+ const headers = new Headers(source);
98
+ ["transfer-encoding", "content-encoding"].forEach((headerName) => {
99
+ headers.delete(headerName);
100
+ });
101
+ return headers;
102
+ }
103
+
104
+ function resolveTargetUrl(req: Request, allowedHosts: ReadonlySet<string>): URL | Response {
105
+ const targetUrl = req.headers.get("x-proxy-url");
106
+
107
+ if (!targetUrl) {
108
+ return new Response("Missing x-proxy-url", { status: 400 });
109
+ }
110
+
111
+ try {
112
+ const parsedUrl = new URL(targetUrl);
113
+ if (allowedHosts.size > 0 && !allowedHosts.has(parsedUrl.hostname)) {
114
+ return new Response(`Host not allowed: ${parsedUrl.hostname}`, { status: 403 });
11
115
  }
12
116
 
13
- const targetUrl = req.headers.get("x-proxy-url");
14
- if (!targetUrl) {
15
- return new Response("Missing x-proxy-url", { status: 400 });
117
+ return parsedUrl;
118
+ } catch {
119
+ return new Response("Invalid x-proxy-url", { status: 400 });
120
+ }
121
+ }
122
+
123
+ function createAbortContext(requestTimeoutMs: number): AbortContext {
124
+ const timeoutController = new AbortController();
125
+ const timer = setTimeout(() => {
126
+ timeoutController.abort(new DOMException("Upstream request timed out", "TimeoutError"));
127
+ }, requestTimeoutMs);
128
+
129
+ timer.unref?.();
130
+
131
+ return {
132
+ timeoutSignal: timeoutController.signal,
133
+ cancelTimeout(): void {
134
+ clearTimeout(timer);
135
+ },
136
+ };
137
+ }
138
+
139
+ function isAbortError(error: unknown): boolean {
140
+ return error instanceof DOMException
141
+ ? error.name === "AbortError" || error.name === "TimeoutError"
142
+ : error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
143
+ }
144
+
145
+ function isTimeoutAbort(signal: AbortSignal): boolean {
146
+ const reason = signal.reason;
147
+ return reason instanceof DOMException
148
+ ? reason.name === "TimeoutError"
149
+ : reason instanceof Error && reason.name === "TimeoutError";
150
+ }
151
+
152
+ function createAbortResponse(req: Request, timeoutSignal: AbortSignal): Response {
153
+ return req.signal.aborted
154
+ ? new Response("Client disconnected", { status: 499 })
155
+ : isTimeoutAbort(timeoutSignal)
156
+ ? new Response("Upstream request timed out", { status: 504 })
157
+ : new Response("Upstream request aborted", { status: 499 });
158
+ }
159
+
160
+ async function createUpstreamInit(req: Request, signal: AbortSignal): Promise<RequestInitWithDuplex> {
161
+ const method = req.method || "GET";
162
+ const hasBody = method !== "GET" && method !== "HEAD";
163
+ const bodyText = hasBody ? await req.text() : "";
164
+
165
+ return {
166
+ method,
167
+ headers: sanitizeForwardHeaders(req.headers),
168
+ signal,
169
+ ...(hasBody && bodyText.length > 0 ? { body: bodyText } : {}),
170
+ };
171
+ }
172
+
173
+ function logRequest(targetUrl: URL, req: Request): void {
174
+ if (!DEBUG_ENABLED) {
175
+ return;
176
+ }
177
+
178
+ const logHeaders = Object.fromEntries(
179
+ [...sanitizeForwardHeaders(req.headers).entries()].map(([key, value]) => [
180
+ key,
181
+ key === "authorization" ? "Bearer ***" : value,
182
+ ]),
183
+ );
184
+
185
+ console.error("\n[bun-proxy] === FORWARDED REQUEST ===");
186
+ console.error(`[bun-proxy] ${req.method} ${targetUrl.toString()}`);
187
+ console.error(`[bun-proxy] Headers: ${JSON.stringify(logHeaders, null, 2)}`);
188
+ console.error("[bun-proxy] =========================\n");
189
+ }
190
+
191
+ export function createProxyRequestHandler(options: ProxyRequestHandlerOptions): (req: Request) => Promise<Response> {
192
+ const allowedHosts = new Set(options.allowHosts ?? DEFAULT_ALLOWED_HOSTS);
193
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
194
+
195
+ return async function handleProxyRequest(req: Request): Promise<Response> {
196
+ if (new URL(req.url).pathname === HEALTH_PATH) {
197
+ return new Response("ok");
16
198
  }
17
199
 
18
- const headers = new Headers(req.headers);
19
- headers.delete("x-proxy-url");
20
- headers.delete("host");
21
- headers.delete("connection");
22
-
23
- const body = req.method !== "GET" && req.method !== "HEAD" ? await req.arrayBuffer() : undefined;
24
-
25
- // Log full request for comparison debugging
26
- if (targetUrl.includes("/v1/messages") && !targetUrl.includes("count_tokens")) {
27
- const logHeaders: Record<string, string> = {};
28
- headers.forEach((v, k) => { logHeaders[k] = k === "authorization" ? "Bearer ***" : v; });
29
- let systemPreview = "";
30
- if (body) {
31
- try {
32
- const parsed = JSON.parse(new TextDecoder().decode(body));
33
- if (Array.isArray(parsed.system)) {
34
- systemPreview = JSON.stringify(parsed.system.slice(0, 3).map((b: any) => ({
35
- text: typeof b.text === "string" ? b.text.slice(0, 200) : "(non-text)",
36
- cache_control: b.cache_control,
37
- })), null, 2);
38
- }
39
- } catch { /* ignore */ }
40
- }
41
- console.error(`\n[bun-proxy] === /v1/messages REQUEST ===`);
42
- console.error(`[bun-proxy] URL: ${targetUrl}`);
43
- console.error(`[bun-proxy] Headers: ${JSON.stringify(logHeaders, null, 2)}`);
44
- if (systemPreview) console.error(`[bun-proxy] System blocks (first 3): ${systemPreview}`);
45
- console.error(`[bun-proxy] ===========================\n`);
200
+ const targetUrl = resolveTargetUrl(req, allowedHosts);
201
+ if (targetUrl instanceof Response) {
202
+ return targetUrl;
46
203
  }
47
204
 
205
+ const abortContext = createAbortContext(requestTimeoutMs);
206
+ const upstreamSignal = AbortSignal.any([req.signal, abortContext.timeoutSignal]);
207
+ const upstreamInit = await createUpstreamInit(req, upstreamSignal);
208
+ logRequest(targetUrl, req);
209
+
48
210
  try {
49
- const resp = await fetch(targetUrl, {
50
- method: req.method,
51
- headers,
52
- body,
211
+ const upstreamResponse = await options.fetchImpl(targetUrl.toString(), upstreamInit);
212
+ return new Response(upstreamResponse.body, {
213
+ status: upstreamResponse.status,
214
+ statusText: upstreamResponse.statusText,
215
+ headers: copyResponseHeaders(upstreamResponse.headers),
53
216
  });
217
+ } catch (error) {
218
+ if (upstreamSignal.aborted && isAbortError(error)) {
219
+ return createAbortResponse(req, abortContext.timeoutSignal);
220
+ }
54
221
 
55
- const respHeaders = new Headers(resp.headers);
56
- respHeaders.delete("transfer-encoding");
57
- respHeaders.delete("content-encoding");
222
+ const message = error instanceof Error ? error.message : String(error);
223
+ return new Response(message, { status: 502 });
224
+ } finally {
225
+ abortContext.cancelTimeout();
226
+ }
227
+ };
228
+ }
58
229
 
59
- return new Response(resp.body, {
60
- status: resp.status,
61
- statusText: resp.statusText,
62
- headers: respHeaders,
63
- });
64
- } catch (err: unknown) {
65
- const msg = err instanceof Error ? err.message : String(err);
66
- return new Response(msg, { status: 502 });
230
+ export function createProxyProcessRuntime(options: ProxyProcessRuntimeOptions = {}): ParentWatcher {
231
+ const argv = options.argv ?? process.argv;
232
+ const parentPid = parseParentPid(argv);
233
+ if (!parentPid) {
234
+ return createNoopWatcher();
235
+ }
236
+
237
+ const exit = options.exit ?? process.exit;
238
+ const parentWatcherFactory = options.parentWatcherFactory ?? createDefaultParentWatcherFactory();
239
+
240
+ return parentWatcherFactory({
241
+ parentPid,
242
+ pollIntervalMs: DEFAULT_PARENT_POLL_INTERVAL_MS,
243
+ exitCode: DEFAULT_PARENT_EXIT_CODE,
244
+ onParentExit: (exitCode) => {
245
+ exit(exitCode ?? DEFAULT_PARENT_EXIT_CODE);
246
+ },
247
+ });
248
+ }
249
+
250
+ function assertBunRuntime(): typeof Bun {
251
+ if (typeof Bun === "undefined") {
252
+ throw new Error("bun-proxy.ts must be executed with Bun.");
253
+ }
254
+
255
+ return Bun;
256
+ }
257
+
258
+ async function runProxyProcess(): Promise<void> {
259
+ const bun = assertBunRuntime();
260
+ const watcher = createProxyProcessRuntime();
261
+ const server = bun.serve({
262
+ port: 0,
263
+ fetch: createProxyRequestHandler({
264
+ fetchImpl: fetch,
265
+ allowHosts: DEFAULT_ALLOWED_HOSTS,
266
+ requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
267
+ }),
268
+ });
269
+
270
+ const lifecycle = {
271
+ closed: false,
272
+ };
273
+
274
+ const shutdown = (exitCode = 0): void => {
275
+ if (lifecycle.closed) {
276
+ return;
67
277
  }
68
- },
69
- });
70
278
 
71
- console.log(`BUN_PROXY_PORT=${server.port}`);
279
+ lifecycle.closed = true;
280
+ watcher.stop();
281
+ server.stop(true);
282
+ process.exit(exitCode);
283
+ };
284
+
285
+ process.on("SIGTERM", () => {
286
+ shutdown(0);
287
+ });
288
+
289
+ process.on("SIGINT", () => {
290
+ shutdown(0);
291
+ });
292
+
293
+ watcher.start();
294
+ process.stdout.write(`BUN_PROXY_PORT=${server.port}\n`);
295
+ }
296
+
297
+ if (isMainModule()) {
298
+ void runProxyProcess().catch((error) => {
299
+ const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
300
+ process.stderr.write(`${message}\n`);
301
+ process.exit(1);
302
+ });
303
+ }
@@ -0,0 +1,274 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import { createCircuitBreaker, CircuitBreaker, CircuitState } from "./circuit-breaker.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Circuit Breaker - Core State Tests
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe("CircuitBreaker - State Management", () => {
9
+ it("starts in CLOSED state and allows requests", () => {
10
+ const breaker = createCircuitBreaker({ failureThreshold: 3 });
11
+
12
+ expect(breaker.getState()).toBe(CircuitState.CLOSED);
13
+ expect(breaker.canExecute()).toBe(true);
14
+ });
15
+
16
+ it("transitions to OPEN after N consecutive failures", () => {
17
+ const breaker = createCircuitBreaker({ failureThreshold: 3 });
18
+
19
+ breaker.recordFailure();
20
+ breaker.recordFailure();
21
+ expect(breaker.getState()).toBe(CircuitState.CLOSED);
22
+
23
+ breaker.recordFailure();
24
+ expect(breaker.getState()).toBe(CircuitState.OPEN);
25
+ expect(breaker.canExecute()).toBe(false);
26
+ });
27
+
28
+ it("resets failure count on success", () => {
29
+ const breaker = createCircuitBreaker({ failureThreshold: 3 });
30
+
31
+ breaker.recordFailure();
32
+ breaker.recordFailure();
33
+ breaker.recordSuccess();
34
+
35
+ expect(breaker.getState()).toBe(CircuitState.CLOSED);
36
+ expect(breaker.getFailureCount()).toBe(0);
37
+ });
38
+ });
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Circuit Breaker - Open State Behavior
42
+ // ---------------------------------------------------------------------------
43
+
44
+ describe("CircuitBreaker - Open State", () => {
45
+ it("fails fast without calling upstream when OPEN", () => {
46
+ const breaker = createCircuitBreaker({
47
+ failureThreshold: 1,
48
+ resetTimeoutMs: 5000,
49
+ });
50
+
51
+ breaker.recordFailure();
52
+ expect(breaker.getState()).toBe(CircuitState.OPEN);
53
+
54
+ const upstreamCall = vi.fn();
55
+ const result = breaker.execute(upstreamCall);
56
+
57
+ expect(upstreamCall).not.toHaveBeenCalled();
58
+ expect(result.success).toBe(false);
59
+ expect(result.error).toBe("Circuit breaker is OPEN");
60
+ });
61
+
62
+ it("tracks open state duration", () => {
63
+ const breaker = createCircuitBreaker({
64
+ failureThreshold: 1,
65
+ resetTimeoutMs: 5000,
66
+ });
67
+
68
+ const openTime = Date.now();
69
+ breaker.recordFailure();
70
+
71
+ expect(breaker.getState()).toBe(CircuitState.OPEN);
72
+ expect(breaker.getOpenedAt()).toBeGreaterThanOrEqual(openTime);
73
+ });
74
+ });
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Circuit Breaker - Half-Open State
78
+ // ---------------------------------------------------------------------------
79
+
80
+ describe("CircuitBreaker - Half-Open State", () => {
81
+ it("transitions to HALF_OPEN after timeout expires", async () => {
82
+ const breaker = createCircuitBreaker({
83
+ failureThreshold: 1,
84
+ resetTimeoutMs: 100,
85
+ });
86
+
87
+ breaker.recordFailure();
88
+ expect(breaker.getState()).toBe(CircuitState.OPEN);
89
+
90
+ await new Promise((resolve) => setTimeout(resolve, 150));
91
+
92
+ expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
93
+ expect(breaker.canExecute()).toBe(true);
94
+ });
95
+
96
+ it("closes breaker on success in HALF_OPEN state", () => {
97
+ const breaker = createCircuitBreaker({
98
+ failureThreshold: 1,
99
+ resetTimeoutMs: 0,
100
+ });
101
+
102
+ breaker.recordFailure();
103
+ breaker.transitionToHalfOpen();
104
+ expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
105
+
106
+ breaker.recordSuccess();
107
+ expect(breaker.getState()).toBe(CircuitState.CLOSED);
108
+ expect(breaker.getFailureCount()).toBe(0);
109
+ });
110
+
111
+ it("reopens breaker on failure in HALF_OPEN state", () => {
112
+ const breaker = createCircuitBreaker({
113
+ failureThreshold: 3,
114
+ resetTimeoutMs: 0,
115
+ });
116
+
117
+ breaker.recordFailure();
118
+ breaker.recordFailure();
119
+ breaker.transitionToHalfOpen();
120
+ expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
121
+
122
+ breaker.recordFailure();
123
+ expect(breaker.getState()).toBe(CircuitState.OPEN);
124
+ });
125
+ });
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Circuit Breaker - Per-Client Isolation
129
+ // ---------------------------------------------------------------------------
130
+
131
+ describe("CircuitBreaker - Per-Client Isolation", () => {
132
+ it("client A failures do not affect client B breaker", () => {
133
+ const clientABreaker = createCircuitBreaker({
134
+ clientId: "client-a",
135
+ failureThreshold: 3,
136
+ });
137
+ const clientBBreaker = createCircuitBreaker({
138
+ clientId: "client-b",
139
+ failureThreshold: 3,
140
+ });
141
+
142
+ // Client A fails 3 times
143
+ clientABreaker.recordFailure();
144
+ clientABreaker.recordFailure();
145
+ clientABreaker.recordFailure();
146
+
147
+ // Client A should be OPEN
148
+ expect(clientABreaker.getState()).toBe(CircuitState.OPEN);
149
+ expect(clientABreaker.canExecute()).toBe(false);
150
+
151
+ // Client B should still be CLOSED
152
+ expect(clientBBreaker.getState()).toBe(CircuitState.CLOSED);
153
+ expect(clientBBreaker.canExecute()).toBe(true);
154
+ });
155
+
156
+ it("each client has independent failure count", () => {
157
+ const clientABreaker = createCircuitBreaker({
158
+ clientId: "client-a",
159
+ failureThreshold: 3,
160
+ });
161
+ const clientBBreaker = createCircuitBreaker({
162
+ clientId: "client-b",
163
+ failureThreshold: 3,
164
+ });
165
+
166
+ clientABreaker.recordFailure();
167
+ clientABreaker.recordFailure();
168
+
169
+ expect(clientABreaker.getFailureCount()).toBe(2);
170
+ expect(clientBBreaker.getFailureCount()).toBe(0);
171
+ });
172
+
173
+ it("shared registry returns same breaker for same clientId", () => {
174
+ const breaker1 = createCircuitBreaker({
175
+ clientId: "shared-client",
176
+ failureThreshold: 3,
177
+ });
178
+ const breaker2 = createCircuitBreaker({
179
+ clientId: "shared-client",
180
+ failureThreshold: 3,
181
+ });
182
+
183
+ breaker1.recordFailure();
184
+ breaker1.recordFailure();
185
+
186
+ // Both references should see the same failure count
187
+ expect(breaker2.getFailureCount()).toBe(2);
188
+ });
189
+ });
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Circuit Breaker - Configuration
193
+ // ---------------------------------------------------------------------------
194
+
195
+ describe("CircuitBreaker - Configuration", () => {
196
+ it("uses default values when options not provided", () => {
197
+ const breaker = createCircuitBreaker({});
198
+
199
+ expect(breaker.getConfig().failureThreshold).toBe(5);
200
+ expect(breaker.getConfig().resetTimeoutMs).toBe(30000);
201
+ });
202
+
203
+ it("respects custom failure threshold", () => {
204
+ const breaker = createCircuitBreaker({ failureThreshold: 10 });
205
+
206
+ for (let i = 0; i < 9; i++) {
207
+ breaker.recordFailure();
208
+ }
209
+ expect(breaker.getState()).toBe(CircuitState.CLOSED);
210
+
211
+ breaker.recordFailure();
212
+ expect(breaker.getState()).toBe(CircuitState.OPEN);
213
+ });
214
+
215
+ it("respects custom reset timeout", async () => {
216
+ const breaker = createCircuitBreaker({
217
+ failureThreshold: 1,
218
+ resetTimeoutMs: 50,
219
+ });
220
+
221
+ breaker.recordFailure();
222
+ expect(breaker.getState()).toBe(CircuitState.OPEN);
223
+
224
+ await new Promise((resolve) => setTimeout(resolve, 60));
225
+ expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
226
+ });
227
+ });
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Circuit Breaker - Execute Wrapper
231
+ // ---------------------------------------------------------------------------
232
+
233
+ describe("CircuitBreaker - Execute Wrapper", () => {
234
+ it("executes upstream function when CLOSED", async () => {
235
+ const breaker = createCircuitBreaker({ failureThreshold: 3 });
236
+ const upstreamFn = vi.fn().mockResolvedValue("success");
237
+
238
+ const result = await breaker.execute(upstreamFn);
239
+
240
+ expect(upstreamFn).toHaveBeenCalledTimes(1);
241
+ expect(result.success).toBe(true);
242
+ expect(result.data).toBe("success");
243
+ });
244
+
245
+ it("records success when upstream succeeds", async () => {
246
+ const breaker = createCircuitBreaker({ failureThreshold: 3 });
247
+ const upstreamFn = vi.fn().mockResolvedValue("data");
248
+
249
+ await breaker.execute(upstreamFn);
250
+
251
+ expect(breaker.getFailureCount()).toBe(0);
252
+ expect(breaker.getState()).toBe(CircuitState.CLOSED);
253
+ });
254
+
255
+ it("records failure when upstream throws", async () => {
256
+ const breaker = createCircuitBreaker({ failureThreshold: 3 });
257
+ const upstreamFn = vi.fn().mockRejectedValue(new Error("upstream error"));
258
+
259
+ await breaker.execute(upstreamFn);
260
+
261
+ expect(breaker.getFailureCount()).toBe(1);
262
+ });
263
+
264
+ it("returns error result when upstream throws", async () => {
265
+ const breaker = createCircuitBreaker({ failureThreshold: 3 });
266
+ const error = new Error("upstream error");
267
+ const upstreamFn = vi.fn().mockRejectedValue(error);
268
+
269
+ const result = await breaker.execute(upstreamFn);
270
+
271
+ expect(result.success).toBe(false);
272
+ expect(result.error).toBe("upstream error");
273
+ });
274
+ });