diff --git a/async/unstable_circuit_breaker.ts b/async/unstable_circuit_breaker.ts index 901bc1c35af4..b3b1826b4e4b 100644 --- a/async/unstable_circuit_breaker.ts +++ b/async/unstable_circuit_breaker.ts @@ -6,10 +6,16 @@ * - `"closed"`: Normal operation, requests pass through * - `"open"`: Failing, all requests rejected immediately * - `"half_open"`: Testing recovery, limited requests allowed + * + * @experimental **UNSTABLE**: New API, yet to be vetted. */ export type CircuitState = "closed" | "open" | "half_open"; -/** Options for {@linkcode CircuitBreaker}. */ +/** + * Options for {@linkcode CircuitBreaker}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ export interface CircuitBreakerOptions { /** * Number of failures before opening the circuit. @@ -120,7 +126,11 @@ export interface CircuitBreakerOptions { onClose?: () => void; } -/** Options for {@linkcode CircuitBreaker.execute}. */ +/** + * Options for {@linkcode CircuitBreaker.execute}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ export interface CircuitBreakerExecuteOptions { /** * An optional abort signal that can be used to cancel the operation @@ -166,6 +176,8 @@ export class CircuitBreakerOpenError extends Error { /** * Milliseconds until the circuit breaker cooldown expires. * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @example Usage * ```ts * import { CircuitBreakerOpenError } from "@std/async/unstable-circuit-breaker"; @@ -180,11 +192,13 @@ export class CircuitBreakerOpenError extends Error { /** * Constructs a new {@linkcode CircuitBreakerOpenError} instance. * - * @param remainingCooldownMs Milliseconds until cooldown expires. + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param remainingCooldownMs The number of milliseconds until the circuit breaker exits the open state. */ constructor(remainingCooldownMs: number) { super( - `Circuit breaker is open. Retry after ${remainingCooldownMs}ms.`, + `Circuit breaker is open: retry after ${remainingCooldownMs}ms`, ); this.name = "CircuitBreakerOpenError"; this.remainingCooldownMs = remainingCooldownMs; @@ -199,7 +213,7 @@ interface CircuitBreakerStateBase { readonly halfOpenInFlight: number; } -/** Internal state managed by the circuit breaker */ +/** Internal state managed by the circuit breaker. */ type CircuitBreakerState = | (CircuitBreakerStateBase & { readonly state: "closed"; @@ -228,14 +242,7 @@ function createInitialState(): CircuitBreakerState & { state: "closed" } { }; } -/** - * Removes failure timestamps outside the decay window. - * - * @param timestamps Readonly array of failure timestamps in ms. - * @param windowMs Duration window in milliseconds. - * @param nowMs Current time in milliseconds. - * @returns Readonly filtered array of timestamps within the window. - */ +/** Removes failure timestamps outside the decay window. */ function pruneOldFailures( timestamps: readonly number[], windowMs: number, @@ -253,7 +260,7 @@ function validateOption(name: string, value: number, min: number): void { ? "a finite non-negative number" : `a finite number >= ${min}`; throw new RangeError( - `Cannot create circuit breaker as '${name}' must be ${constraint}: received ${value}`, + `Cannot create circuit breaker: "${name}" must be ${constraint}, received ${value}`, ); } } @@ -386,6 +393,8 @@ export class CircuitBreaker { /** * Constructs a new {@linkcode CircuitBreaker} instance. * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @param options Configuration options for the circuit breaker. * @throws {RangeError} If any numeric option is not a finite number within its valid range. */ @@ -434,6 +443,8 @@ export class CircuitBreaker { * until the next {@linkcode execute} call triggers the transition to * `"half_open"`. * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @example Usage * ```ts * import { CircuitBreaker } from "@std/async/unstable-circuit-breaker"; @@ -455,6 +466,8 @@ export class CircuitBreaker { * The function can be synchronous or asynchronous. The result is always * returned as a promise. * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @example Usage with async function * ```ts ignore * import { CircuitBreaker } from "@std/async/unstable-circuit-breaker"; @@ -477,8 +490,8 @@ export class CircuitBreaker { * ``` * * @typeParam R The return type of the function, must extend T. - * @param fn The function to execute (sync or async). - * @param options Optional execution options including an abort signal. + * @param fn The function to execute through the circuit breaker. + * @param options Options for this execution, such as an abort signal. * @returns A promise that resolves to the result of the operation. * @throws {CircuitBreakerOpenError} If circuit is open. * @throws {DOMException} If the abort signal is already aborted. @@ -544,6 +557,8 @@ export class CircuitBreaker { * Forces the circuit breaker to open state. * Useful for maintenance or known outages. * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @example Usage * ```ts * import { CircuitBreaker } from "@std/async/unstable-circuit-breaker"; @@ -584,6 +599,8 @@ export class CircuitBreaker { * * For silent resets (e.g., in tests), use {@linkcode reset} instead. * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @example Usage * ```ts * import { CircuitBreaker } from "@std/async/unstable-circuit-breaker"; @@ -611,6 +628,8 @@ export class CircuitBreaker { * (`onStateChange`, `onClose`). Use this for testing or administrative * resets where observers should not be notified. * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @example Usage * ```ts * import { CircuitBreaker } from "@std/async/unstable-circuit-breaker"; @@ -628,8 +647,6 @@ export class CircuitBreaker { /** * Resolves the current state, handling automatic transitions. * OPEN → HALF_OPEN after cooldown expires. - * - * @param now Current timestamp in milliseconds. */ #resolveCurrentState(now: number): CircuitBreakerState { if (this.#state.state !== "open") { @@ -653,13 +670,7 @@ export class CircuitBreaker { return this.#state; } - /** - * Records a failure and potentially opens the circuit. - * - * @param error The error that occurred. - * @param previousState The state before this failure. - * @param now Current timestamp in milliseconds. - */ + /** Records a failure and potentially opens the circuit. */ #handleFailure( error: unknown, previousState: CircuitState, diff --git a/async/unstable_circuit_breaker_test.ts b/async/unstable_circuit_breaker_test.ts index 398461243c0b..c81a848b4c2d 100644 --- a/async/unstable_circuit_breaker_test.ts +++ b/async/unstable_circuit_breaker_test.ts @@ -14,102 +14,48 @@ import { type CircuitState, } from "./unstable_circuit_breaker.ts"; -Deno.test("CircuitBreaker constructor throws for invalid failureThreshold", () => { +Deno.test("CircuitBreaker() throws for invalid failureThreshold", () => { assertThrows( () => new CircuitBreaker({ failureThreshold: 0 }), RangeError, - "'failureThreshold' must be a finite number >= 1", + '"failureThreshold" must be a finite number >= 1', ); assertThrows( () => new CircuitBreaker({ failureThreshold: -1 }), RangeError, - "'failureThreshold' must be a finite number >= 1", + '"failureThreshold" must be a finite number >= 1', ); assertThrows( () => new CircuitBreaker({ failureThreshold: NaN }), RangeError, - "'failureThreshold' must be a finite number >= 1", + '"failureThreshold" must be a finite number >= 1', ); assertThrows( () => new CircuitBreaker({ failureThreshold: Infinity }), RangeError, - "'failureThreshold' must be a finite number >= 1", + '"failureThreshold" must be a finite number >= 1', ); }); -Deno.test("CircuitBreaker constructor throws for invalid cooldownMs", () => { +Deno.test("CircuitBreaker() throws for invalid cooldownMs", () => { assertThrows( () => new CircuitBreaker({ cooldownMs: -1 }), RangeError, - "'cooldownMs' must be a finite non-negative number", + '"cooldownMs" must be a finite non-negative number', ); assertThrows( () => new CircuitBreaker({ cooldownMs: NaN }), RangeError, - "'cooldownMs' must be a finite non-negative number", + '"cooldownMs" must be a finite non-negative number', ); assertThrows( () => new CircuitBreaker({ cooldownMs: Infinity }), RangeError, - "'cooldownMs' must be a finite non-negative number", + '"cooldownMs" must be a finite non-negative number', ); }); -Deno.test("CircuitBreaker constructor throws for invalid successThreshold", () => { - assertThrows( - () => new CircuitBreaker({ successThreshold: 0 }), - RangeError, - "'successThreshold' must be a finite number >= 1", - ); - assertThrows( - () => new CircuitBreaker({ successThreshold: NaN }), - RangeError, - "'successThreshold' must be a finite number >= 1", - ); - assertThrows( - () => new CircuitBreaker({ successThreshold: Infinity }), - RangeError, - "'successThreshold' must be a finite number >= 1", - ); -}); - -Deno.test("CircuitBreaker constructor throws for invalid halfOpenMaxConcurrent", () => { - assertThrows( - () => new CircuitBreaker({ halfOpenMaxConcurrent: 0 }), - RangeError, - "'halfOpenMaxConcurrent' must be a finite number >= 1", - ); - assertThrows( - () => new CircuitBreaker({ halfOpenMaxConcurrent: NaN }), - RangeError, - "'halfOpenMaxConcurrent' must be a finite number >= 1", - ); - assertThrows( - () => new CircuitBreaker({ halfOpenMaxConcurrent: Infinity }), - RangeError, - "'halfOpenMaxConcurrent' must be a finite number >= 1", - ); -}); - -Deno.test("CircuitBreaker constructor throws for invalid failureWindowMs", () => { - assertThrows( - () => new CircuitBreaker({ failureWindowMs: -1 }), - RangeError, - "'failureWindowMs' must be a finite non-negative number", - ); - assertThrows( - () => new CircuitBreaker({ failureWindowMs: NaN }), - RangeError, - "'failureWindowMs' must be a finite non-negative number", - ); - assertThrows( - () => new CircuitBreaker({ failureWindowMs: Infinity }), - RangeError, - "'failureWindowMs' must be a finite non-negative number", - ); -}); - -Deno.test("CircuitBreaker constructor defaults work correctly", () => { +Deno.test("CircuitBreaker() defaults produce closed state", () => { const breaker = new CircuitBreaker(); assertEquals(breaker.state, "closed"); }); @@ -132,7 +78,7 @@ Deno.test("CircuitBreaker.execute() throws on failure", async () => { ); }); -Deno.test("CircuitBreaker opens after reaching failure threshold", async () => { +Deno.test("CircuitBreaker.execute() opens circuit after reaching failure threshold", async () => { const breaker = new CircuitBreaker({ failureThreshold: 3 }); const error = new Error("fail"); @@ -152,7 +98,7 @@ Deno.test("CircuitBreaker opens after reaching failure threshold", async () => { assertEquals(breaker.state, "open"); }); -Deno.test("CircuitBreaker throws CircuitBreakerOpenError when open", async () => { +Deno.test("CircuitBreaker.execute() throws CircuitBreakerOpenError when open", async () => { const breaker = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 30000, @@ -170,19 +116,23 @@ Deno.test("CircuitBreaker throws CircuitBreakerOpenError when open", async () => const openError = await assertRejects( () => breaker.execute(() => Promise.resolve("ignored")), CircuitBreakerOpenError, - "Circuit breaker is open", + "Circuit breaker is open: retry after", ); assertInstanceOf(openError, CircuitBreakerOpenError); assert(openError.remainingCooldownMs > 0); }); -Deno.test("CircuitBreaker transitions to half_open after cooldown", async () => { +Deno.test("CircuitBreaker.execute() transitions to half_open after cooldown", async () => { using time = new FakeTime(); + let halfOpenCalled = false; const breaker = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 1000, successThreshold: 1, + onHalfOpen: () => { + halfOpenCalled = true; + }, }); // Open the circuit @@ -190,6 +140,7 @@ Deno.test("CircuitBreaker transitions to half_open after cooldown", async () => await breaker.execute(() => Promise.reject(new Error("fail"))); } catch { /* expected */ } assertEquals(breaker.state, "open"); + assertEquals(halfOpenCalled, false); // Advance time past cooldown time.tick(1001); @@ -197,9 +148,10 @@ Deno.test("CircuitBreaker transitions to half_open after cooldown", async () => // execute() resolves time-based transitions await breaker.execute(() => Promise.resolve("ok")); assertEquals(breaker.state, "closed"); + assertEquals(halfOpenCalled, true); }); -Deno.test("CircuitBreaker state getter is pure and does not trigger transitions", async () => { +Deno.test("CircuitBreaker.state does not trigger transitions", async () => { using time = new FakeTime(); const transitions: Array<[CircuitState, CircuitState]> = []; @@ -239,40 +191,17 @@ Deno.test("CircuitBreaker state getter is pure and does not trigger transitions" ]); }); -Deno.test("CircuitBreaker execute() resolves stale state before checking", async () => { - using time = new FakeTime(); - - const breaker = new CircuitBreaker({ - failureThreshold: 1, - cooldownMs: 1000, - successThreshold: 1, - }); - - // Open the circuit - try { - await breaker.execute(() => Promise.reject(new Error("fail"))); - } catch { /* expected */ } - assertEquals(breaker.state, "open"); - - // Advance time past cooldown - time.tick(1001); - - // State is stale (still shows open) - assertEquals(breaker.state, "open"); - - // But execute() should resolve the transition and succeed - const result = await breaker.execute(() => Promise.resolve("success")); - assertEquals(result, "success"); - assertEquals(breaker.state, "closed"); // Now closed after successful execution -}); - -Deno.test("CircuitBreaker closes from half_open after success threshold", async () => { +Deno.test("CircuitBreaker.execute() closes from half_open after success threshold", async () => { using time = new FakeTime(); + let closeCalled = false; const breaker = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 1000, successThreshold: 2, + onClose: () => { + closeCalled = true; + }, }); // Open the circuit @@ -286,13 +215,15 @@ Deno.test("CircuitBreaker closes from half_open after success threshold", async // First success (also triggers half_open transition) await breaker.execute(() => Promise.resolve("ok")); assertEquals(breaker.state, "half_open"); + assertEquals(closeCalled, false); // Second success - should close await breaker.execute(() => Promise.resolve("ok")); assertEquals(breaker.state, "closed"); + assertEquals(closeCalled, true); }); -Deno.test("CircuitBreaker reopens from half_open on failure", async () => { +Deno.test("CircuitBreaker.execute() reopens from half_open on failure", async () => { using time = new FakeTime(); const transitions: Array<[CircuitState, CircuitState]> = []; @@ -368,7 +299,7 @@ Deno.test("CircuitBreaker.reset() resets to initial state", async () => { assertEquals(breaker.state, "closed"); }); -Deno.test("CircuitBreaker isFailure predicate filters errors and onFailure callback", async () => { +Deno.test("CircuitBreaker.execute() filters errors with isFailure predicate", async () => { const failures: Array<{ error: unknown; count: number }> = []; const breaker = new CircuitBreaker({ failureThreshold: 2, @@ -401,7 +332,7 @@ Deno.test("CircuitBreaker isFailure predicate filters errors and onFailure callb assertEquals(breaker.state, "open"); }); -Deno.test("CircuitBreaker isResultFailure counts successful results as failures", async () => { +Deno.test("CircuitBreaker.execute() counts successful results as failures via isResultFailure", async () => { const breaker = new CircuitBreaker({ failureThreshold: 1, isResultFailure: (result) => result < 0, @@ -418,32 +349,7 @@ Deno.test("CircuitBreaker isResultFailure counts successful results as failures" assertEquals(breaker.state, "open"); }); -Deno.test("CircuitBreaker isResultFailure works with custom isFailure predicate", async () => { - const failures: Array<{ error: unknown; count: number }> = []; - const breaker = new CircuitBreaker({ - failureThreshold: 1, - // Only count TypeError as thrown-error failures - isFailure: (error) => error instanceof TypeError, - // Also count negative results as failures - isResultFailure: (result) => result < 0, - onFailure: (error, count) => failures.push({ error, count }), - }); - - // Regular Error throw should NOT count (isFailure filters it out) - try { - await breaker.execute(() => Promise.reject(new Error("ignored"))); - } catch { /* expected */ } - assertEquals(failures.length, 0); - assertEquals(breaker.state, "closed"); - - // Negative result should count as failure regardless of isFailure predicate - const result = await breaker.execute(() => Promise.resolve(-1)); - assertEquals(result, -1); - assertEquals(failures.length, 1); - assertEquals(breaker.state, "open"); -}); - -Deno.test("CircuitBreaker failure window prunes old failures", async () => { +Deno.test("CircuitBreaker.execute() prunes failures outside the failure window", async () => { using time = new FakeTime(); const failures: number[] = []; @@ -482,34 +388,36 @@ Deno.test("CircuitBreaker failure window prunes old failures", async () => { assertEquals(breaker.state, "open"); }); -Deno.test("CircuitBreaker onStateChange callback is invoked", async () => { +Deno.test("CircuitBreaker.execute() never prunes failures when failureWindowMs is 0", async () => { using time = new FakeTime(); - const transitions: Array<[CircuitState, CircuitState]> = []; + const failures: number[] = []; const breaker = new CircuitBreaker({ - failureThreshold: 1, - cooldownMs: 1000, - successThreshold: 1, - onStateChange: (from, to) => transitions.push([from, to]), + failureThreshold: 3, + failureWindowMs: 0, // Disabled - failures never expire + onFailure: (_error, count) => failures.push(count), }); - // Open + // Add two failures + try { + await breaker.execute(() => Promise.reject(new Error("fail"))); + } catch { /* expected */ } try { await breaker.execute(() => Promise.reject(new Error("fail"))); } catch { /* expected */ } - assertEquals(transitions, [["closed", "open"]]); - // Half-open and close (execute resolves the transition) - time.tick(1001); - await breaker.execute(() => Promise.resolve("ok")); - assertEquals(transitions, [ - ["closed", "open"], - ["open", "half_open"], - ["half_open", "closed"], - ]); + // Advance time significantly + time.tick(100_000); + + // One more should open (failures persisted since window is disabled) + try { + await breaker.execute(() => Promise.reject(new Error("fail"))); + } catch { /* expected */ } + assertEquals(failures, [1, 2, 3]); // Count continues from 2 + assertEquals(breaker.state, "open"); }); -Deno.test("CircuitBreaker onFailure callback is invoked", async () => { +Deno.test("CircuitBreaker.execute() invokes onFailure callback", async () => { const failures: Array<{ error: unknown; count: number }> = []; const breaker = new CircuitBreaker({ failureThreshold: 3, @@ -533,7 +441,7 @@ Deno.test("CircuitBreaker onFailure callback is invoked", async () => { assertEquals(failures[1]?.count, 2); }); -Deno.test("CircuitBreaker onOpen callback is invoked", async () => { +Deno.test("CircuitBreaker.execute() invokes onOpen callback", async () => { const openCalls: number[] = []; const breaker = new CircuitBreaker({ failureThreshold: 2, @@ -551,58 +459,7 @@ Deno.test("CircuitBreaker onOpen callback is invoked", async () => { assertEquals(openCalls, [2]); }); -Deno.test("CircuitBreaker onClose callback is invoked", async () => { - using time = new FakeTime(); - - let closeCalled = false; - const breaker = new CircuitBreaker({ - failureThreshold: 1, - cooldownMs: 1000, - successThreshold: 1, - onClose: () => { - closeCalled = true; - }, - }); - - // Open - try { - await breaker.execute(() => Promise.reject(new Error("fail"))); - } catch { /* expected */ } - - // Half-open - time.tick(1001); - - // Close - await breaker.execute(() => Promise.resolve("ok")); - assertEquals(closeCalled, true); -}); - -Deno.test("CircuitBreaker onHalfOpen callback is invoked", async () => { - using time = new FakeTime(); - - let halfOpenCalled = false; - const breaker = new CircuitBreaker({ - failureThreshold: 1, - cooldownMs: 1000, - successThreshold: 1, - onHalfOpen: () => { - halfOpenCalled = true; - }, - }); - - // Open - try { - await breaker.execute(() => Promise.reject(new Error("fail"))); - } catch { /* expected */ } - assertEquals(halfOpenCalled, false); - - // Enter half-open (execute resolves the transition) - time.tick(1001); - await breaker.execute(() => Promise.resolve("ok")); - assertEquals(halfOpenCalled, true); -}); - -Deno.test("CircuitBreaker half_open limits concurrent requests", async () => { +Deno.test("CircuitBreaker.execute() limits concurrent requests in half_open", async () => { using time = new FakeTime(); const breaker = new CircuitBreaker({ @@ -640,250 +497,7 @@ Deno.test("CircuitBreaker half_open limits concurrent requests", async () => { await firstPromise; }); -Deno.test("CircuitBreaker with disabled failure window (0)", async () => { - using time = new FakeTime(); - - const failures: number[] = []; - const breaker = new CircuitBreaker({ - failureThreshold: 3, - failureWindowMs: 0, // Disabled - failures never expire - onFailure: (_error, count) => failures.push(count), - }); - - // Add two failures - try { - await breaker.execute(() => Promise.reject(new Error("fail"))); - } catch { /* expected */ } - try { - await breaker.execute(() => Promise.reject(new Error("fail"))); - } catch { /* expected */ } - - // Advance time significantly - time.tick(100_000); - - // One more should open (failures persisted since window is disabled) - try { - await breaker.execute(() => Promise.reject(new Error("fail"))); - } catch { /* expected */ } - assertEquals(failures, [1, 2, 3]); // Count continues from 2 - assertEquals(breaker.state, "open"); -}); - -Deno.test("CircuitBreaker with zero cooldown transitions immediately", async () => { - const breaker = new CircuitBreaker({ - failureThreshold: 1, - cooldownMs: 0, - successThreshold: 1, - }); - - // Open - try { - await breaker.execute(() => Promise.reject(new Error("fail"))); - } catch { /* expected */ } - - // Should be able to execute and close immediately (cooldown is 0) - await breaker.execute(() => Promise.resolve("ok")); - assertEquals(breaker.state, "closed"); -}); - -Deno.test("CircuitBreakerOpenError has correct properties", () => { - const error = new CircuitBreakerOpenError(5000); - - assertEquals(error.name, "CircuitBreakerOpenError"); - assertEquals(error.remainingCooldownMs, 5000); - assert(error.message.includes("5000ms")); - assertInstanceOf(error, Error); -}); - -Deno.test("CircuitBreaker type parameter constrains isResultFailure", async () => { - // This test verifies the generic type works correctly - interface ApiResponse { - status: number; - data: string; - } - - const breaker = new CircuitBreaker({ - failureThreshold: 1, - isResultFailure: (response) => response.status >= 500, - }); - - const result = await breaker.execute(() => - Promise.resolve({ status: 200, data: "ok" }) - ); - assertEquals(result.status, 200); - assertEquals(breaker.state, "closed"); - - // 500 error counts as failure - await breaker.execute(() => Promise.resolve({ status: 500, data: "error" })); - assertEquals(breaker.state, "open"); -}); - -Deno.test("CircuitBreaker multiple force operations", () => { - const transitions: Array<[CircuitState, CircuitState]> = []; - const breaker = new CircuitBreaker({ - onStateChange: (from, to) => transitions.push([from, to]), - }); - - // Repeated forceOpen should only trigger one transition - breaker.forceOpen(); - breaker.forceOpen(); - assertEquals(transitions.length, 1); - - // Repeated forceClose should only trigger one transition - breaker.forceClose(); - breaker.forceClose(); - assertEquals(transitions.length, 2); -}); - -Deno.test("CircuitBreaker.reset() silently resets without invoking callbacks", async () => { - const transitions: Array<[CircuitState, CircuitState]> = []; - let closeCalled = false; - const breaker = new CircuitBreaker({ - failureThreshold: 1, - onStateChange: (from, to) => transitions.push([from, to]), - onClose: () => closeCalled = true, - }); - - // Open the circuit - try { - await breaker.execute(() => Promise.reject(new Error("fail"))); - } catch { /* expected */ } - assertEquals(breaker.state, "open"); - assertEquals(transitions, [["closed", "open"]]); - - // Reset should NOT trigger any callbacks (silent reset) - breaker.reset(); - assertEquals(breaker.state, "closed"); - assertEquals(transitions, [["closed", "open"]]); // No new transition - assertEquals(closeCalled, false); // onClose not called -}); - -Deno.test("CircuitBreaker.forceClose() invokes callbacks unlike reset()", async () => { - const transitions: Array<[CircuitState, CircuitState]> = []; - let closeCalled = false; - const breaker = new CircuitBreaker({ - failureThreshold: 1, - onStateChange: (from, to) => transitions.push([from, to]), - onClose: () => closeCalled = true, - }); - - // Open the circuit - try { - await breaker.execute(() => Promise.reject(new Error("fail"))); - } catch { /* expected */ } - assertEquals(breaker.state, "open"); - - // forceClose SHOULD trigger callbacks - breaker.forceClose(); - assertEquals(breaker.state, "closed"); - assertEquals(transitions, [["closed", "open"], ["open", "closed"]]); - assertEquals(closeCalled, true); -}); - -Deno.test("CircuitBreaker half_open failure invokes onStateChange and onOpen", async () => { - using time = new FakeTime(); - - const transitions: Array<[CircuitState, CircuitState]> = []; - const openCalls: number[] = []; - const breaker = new CircuitBreaker({ - failureThreshold: 1, - cooldownMs: 1000, - onStateChange: (from, to) => transitions.push([from, to]), - onOpen: (count) => openCalls.push(count), - }); - - // Open the circuit - try { - await breaker.execute(() => Promise.reject(new Error("fail"))); - } catch { /* expected */ } - assertEquals(breaker.state, "open"); - assertEquals(transitions, [["closed", "open"]]); - assertEquals(openCalls, [1]); - - // Advance past cooldown, then fail (triggers half_open then reopen) - time.tick(1001); - - // Failure in half-open should reopen and invoke callbacks - try { - await breaker.execute(() => Promise.reject(new Error("fail again"))); - } catch { /* expected */ } - assertEquals(breaker.state, "open"); - assertEquals(transitions, [ - ["closed", "open"], - ["open", "half_open"], - ["half_open", "open"], - ]); - assertEquals(openCalls, [1, 2]); // Second open call with 2 failures -}); - -Deno.test("CircuitBreaker closes after consecutiveSuccesses reaches threshold", async () => { - using time = new FakeTime(); - - const transitions: Array<[CircuitState, CircuitState]> = []; - const breaker = new CircuitBreaker({ - failureThreshold: 1, - cooldownMs: 1000, - successThreshold: 3, - onStateChange: (from, to) => transitions.push([from, to]), - }); - - // Open the circuit - try { - await breaker.execute(() => Promise.reject(new Error("fail"))); - } catch { /* expected */ } - - // Advance past cooldown - time.tick(1001); - - // First success (triggers half_open transition) - await breaker.execute(() => Promise.resolve("ok")); - assertEquals(breaker.state, "half_open"); - - // Second success - await breaker.execute(() => Promise.resolve("ok")); - assertEquals(breaker.state, "half_open"); - - // Third success closes the circuit - await breaker.execute(() => Promise.resolve("ok")); - assertEquals(breaker.state, "closed"); - assertEquals(transitions, [ - ["closed", "open"], - ["open", "half_open"], - ["half_open", "closed"], - ]); -}); - -Deno.test("CircuitBreaker isResultFailure in half_open reopens circuit", async () => { - using time = new FakeTime(); - - const transitions: Array<[CircuitState, CircuitState]> = []; - const breaker = new CircuitBreaker({ - failureThreshold: 1, - cooldownMs: 1000, - isResultFailure: (result) => result < 0, - onStateChange: (from, to) => transitions.push([from, to]), - }); - - // Open the circuit with a thrown error - try { - await breaker.execute(() => Promise.reject(new Error("fail"))); - } catch { /* expected */ } - - // Advance past cooldown - time.tick(1001); - - // Result failure in half-open should reopen (execute triggers half_open first) - const result = await breaker.execute(() => Promise.resolve(-1)); - assertEquals(result, -1); // Still returns the result - assertEquals(breaker.state, "open"); // But circuit is reopened - assertEquals(transitions, [ - ["closed", "open"], - ["open", "half_open"], - ["half_open", "open"], - ]); -}); - -Deno.test("CircuitBreaker concurrent half_open failure prevents stale success from closing circuit", async () => { +Deno.test("CircuitBreaker.execute() prevents stale half_open success from closing after concurrent failure", async () => { using time = new FakeTime(); const transitions: Array<[CircuitState, CircuitState]> = []; @@ -941,17 +555,18 @@ Deno.test("CircuitBreaker concurrent half_open failure prevents stale success fr ]); }); -Deno.test("CircuitBreaker handles multiple half_open concurrent slots", async () => { +Deno.test("CircuitBreaker.execute() reopens circuit via isResultFailure in half_open", async () => { using time = new FakeTime(); - const breaker = new CircuitBreaker({ + const transitions: Array<[CircuitState, CircuitState]> = []; + const breaker = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 1000, - halfOpenMaxConcurrent: 2, - successThreshold: 2, + isResultFailure: (result) => result < 0, + onStateChange: (from, to) => transitions.push([from, to]), }); - // Open + // Open the circuit with a thrown error try { await breaker.execute(() => Promise.reject(new Error("fail"))); } catch { /* expected */ } @@ -959,58 +574,64 @@ Deno.test("CircuitBreaker handles multiple half_open concurrent slots", async () // Advance past cooldown time.tick(1001); - // Start two concurrent requests (should both be allowed, first triggers half_open) - let resolve1: (() => void) | undefined; - let resolve2: (() => void) | undefined; - const promise1 = breaker.execute( - () => - new Promise((r) => { - resolve1 = () => r("first"); - }), - ); - const promise2 = breaker.execute( - () => - new Promise((r) => { - resolve2 = () => r("second"); - }), - ); - - // Third request should be rejected (at max concurrent) - await assertRejects( - () => breaker.execute(() => Promise.resolve("third")), - CircuitBreakerOpenError, - ); + // Result failure in half-open should reopen (execute triggers half_open first) + const result = await breaker.execute(() => Promise.resolve(-1)); + assertEquals(result, -1); // Still returns the result + assertEquals(breaker.state, "open"); // But circuit is reopened + assertEquals(transitions, [ + ["closed", "open"], + ["open", "half_open"], + ["half_open", "open"], + ]); +}); - // Complete both requests - resolve1?.(); - resolve2?.(); - await promise1; - await promise2; +Deno.test("CircuitBreakerOpenError() has correct properties", () => { + const error = new CircuitBreakerOpenError(5000); - assertEquals(breaker.state, "closed"); // Both successes met threshold + assertEquals(error.name, "CircuitBreakerOpenError"); + assertEquals(error.remainingCooldownMs, 5000); + assert(error.message.includes("5000ms")); + assertInstanceOf(error, Error); }); -Deno.test("CircuitBreaker.execute() accepts sync functions", async () => { - const breaker = new CircuitBreaker(); - const result = await breaker.execute(() => "sync result"); - assertEquals(result, "sync result"); - assertEquals(breaker.state, "closed"); -}); +Deno.test("CircuitBreaker.forceOpen() and forceClose() deduplicate repeated calls", () => { + const transitions: Array<[CircuitState, CircuitState]> = []; + const breaker = new CircuitBreaker({ + onStateChange: (from, to) => transitions.push([from, to]), + }); -Deno.test("CircuitBreaker.execute() handles sync function that throws", async () => { - const breaker = new CircuitBreaker({ failureThreshold: 1 }); - const error = new Error("sync error"); + // Repeated forceOpen should only trigger one transition + breaker.forceOpen(); + breaker.forceOpen(); + assertEquals(transitions.length, 1); - await assertRejects( - () => - breaker.execute(() => { - throw error; - }), - Error, - "sync error", - ); + // Repeated forceClose should only trigger one transition + breaker.forceClose(); + breaker.forceClose(); + assertEquals(transitions.length, 2); +}); + +Deno.test("CircuitBreaker.reset() silently resets without invoking callbacks", async () => { + const transitions: Array<[CircuitState, CircuitState]> = []; + let closeCalled = false; + const breaker = new CircuitBreaker({ + failureThreshold: 1, + onStateChange: (from, to) => transitions.push([from, to]), + onClose: () => closeCalled = true, + }); + // Open the circuit + try { + await breaker.execute(() => Promise.reject(new Error("fail"))); + } catch { /* expected */ } assertEquals(breaker.state, "open"); + assertEquals(transitions, [["closed", "open"]]); + + // Reset should NOT trigger any callbacks (silent reset) + breaker.reset(); + assertEquals(breaker.state, "closed"); + assertEquals(transitions, [["closed", "open"]]); // No new transition + assertEquals(closeCalled, false); // onClose not called }); Deno.test("CircuitBreaker.execute() rejects immediately if signal already aborted", async () => { @@ -1035,7 +656,7 @@ Deno.test("CircuitBreaker.execute() rejects immediately if signal already aborte assertEquals(breaker.state, "closed"); }); -Deno.test("CircuitBreaker throwing onFailure does not mask original error and later callbacks still fire", async () => { +Deno.test("CircuitBreaker.execute() does not mask original error when onFailure throws", async () => { const transitions: Array<[CircuitState, CircuitState]> = []; const openCalls: number[] = []; const asyncErrors: Error[] = []; @@ -1083,64 +704,3 @@ Deno.test("CircuitBreaker throwing onFailure does not mask original error and la globalThis.removeEventListener("error", onError); } }); - -Deno.test("CircuitBreaker throwing onStateChange during success does not prevent result return", async () => { - using time = new FakeTime(); - - // Intercept asynchronously re-thrown callback errors - const onError = (event: ErrorEvent) => { - event.preventDefault(); - }; - globalThis.addEventListener("error", onError); - - try { - const breaker = new CircuitBreaker({ - failureThreshold: 1, - cooldownMs: 1000, - successThreshold: 1, - onStateChange: () => { - throw new Error("observer bug"); - }, - }); - - // Open the circuit (onStateChange throws here too, but circuit still opens) - try { - await breaker.execute(() => Promise.reject(new Error("fail"))); - } catch { /* expected */ } - assertEquals(breaker.state, "open"); - - // Advance past cooldown - time.tick(1001); - - // Success triggers half_open→closed transition where onStateChange throws, - // but the result must still be returned - const result = await breaker.execute(() => Promise.resolve("recovered")); - assertEquals(result, "recovered"); - assertEquals(breaker.state, "closed"); - } finally { - globalThis.removeEventListener("error", onError); - } -}); - -Deno.test("CircuitBreaker abort does not count as circuit failure", async () => { - const failures: Array<{ error: unknown; count: number }> = []; - const breaker = new CircuitBreaker({ - failureThreshold: 3, - onFailure: (error, count) => failures.push({ error, count }), - }); - - const controller = new AbortController(); - controller.abort(); - - // Multiple aborted executions should not affect circuit state - for (let i = 0; i < 5; i++) { - try { - await breaker.execute(() => Promise.resolve("ignored"), { - signal: controller.signal, - }); - } catch { /* expected */ } - } - - assertEquals(failures.length, 0); - assertEquals(breaker.state, "closed"); -});