@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.
- package/README.md +19 -0
- package/dist/bun-proxy.mjs +282 -55
- package/dist/opencode-anthropic-auth-cli.mjs +194 -55
- package/dist/opencode-anthropic-auth-plugin.js +1816 -594
- package/package.json +1 -1
- package/src/__tests__/billing-edge-cases.test.ts +84 -0
- package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
- package/src/__tests__/debug-gating.test.ts +76 -0
- package/src/__tests__/decomposition-smoke.test.ts +92 -0
- package/src/__tests__/fingerprint-regression.test.ts +1 -1
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
- package/src/__tests__/helpers/conversation-history.ts +376 -0
- package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
- package/src/__tests__/helpers/deferred.ts +122 -0
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
- package/src/__tests__/helpers/in-memory-storage.ts +152 -0
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
- package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
- package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
- package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
- package/src/__tests__/helpers/sse.ts +288 -0
- package/src/__tests__/index.parallel.test.ts +711 -0
- package/src/__tests__/sanitization-regex.test.ts +65 -0
- package/src/__tests__/state-bounds.test.ts +110 -0
- package/src/account-identity.test.ts +213 -0
- package/src/account-identity.ts +108 -0
- package/src/accounts.dedup.test.ts +696 -0
- package/src/accounts.test.ts +2 -1
- package/src/accounts.ts +485 -191
- package/src/bun-fetch.test.ts +379 -0
- package/src/bun-fetch.ts +447 -174
- package/src/bun-proxy.ts +289 -57
- package/src/circuit-breaker.test.ts +274 -0
- package/src/circuit-breaker.ts +235 -0
- package/src/cli.test.ts +1 -0
- package/src/cli.ts +37 -18
- package/src/commands/router.ts +25 -5
- package/src/env.ts +1 -0
- package/src/headers/billing.ts +31 -13
- package/src/index.ts +224 -247
- package/src/oauth.ts +7 -1
- package/src/parent-pid-watcher.test.ts +219 -0
- package/src/parent-pid-watcher.ts +99 -0
- package/src/plugin-helpers.ts +112 -0
- package/src/refresh-helpers.ts +169 -0
- package/src/refresh-lock.test.ts +36 -9
- package/src/refresh-lock.ts +2 -2
- package/src/request/body.history.test.ts +398 -0
- package/src/request/body.ts +200 -13
- package/src/request/metadata.ts +6 -2
- package/src/response/index.ts +1 -1
- package/src/response/mcp.ts +60 -31
- package/src/response/streaming.test.ts +382 -0
- package/src/response/streaming.ts +403 -76
- package/src/storage.test.ts +127 -104
- package/src/storage.ts +152 -62
- package/src/system-prompt/builder.ts +33 -3
- package/src/system-prompt/sanitize.ts +12 -2
- package/src/token-refresh.test.ts +84 -1
- package/src/token-refresh.ts +14 -8
package/src/bun-proxy.ts
CHANGED
|
@@ -1,71 +1,303 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import { ParentPidWatcher } from "./parent-pid-watcher.js";
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
+
});
|