From 0a4f638ad15c96dcf8dee5a70bd09dfe9950d679 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Sun, 6 Sep 2026 09:47:43 +0200 Subject: [PATCH] fix(async/unstable): evaluate circuit breaker failure rate on success --- async/unstable_circuit_breaker.ts | 31 ++++++++++++++++++++++---- async/unstable_circuit_breaker_test.ts | 30 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/async/unstable_circuit_breaker.ts b/async/unstable_circuit_breaker.ts index 38107103d8ab..bc93879d25a0 100644 --- a/async/unstable_circuit_breaker.ts +++ b/async/unstable_circuit_breaker.ts @@ -758,8 +758,7 @@ export class CircuitBreaker { const failureCount = this.#failures.total; const shouldOpen = previousState === "half_open" || - (totalRequests >= this.#minimumThroughput && - failureCount / totalRequests >= this.#failureRateThreshold); + this.#exceedsFailureRate(); const existingOpenedAt = this.#state.state === "open" ? this.#state.openedAt @@ -790,9 +789,33 @@ export class CircuitBreaker { } } - /** Records a success and potentially closes the circuit from half-open. */ + /** Whether the window has enough requests and the rate meets the threshold. */ + #exceedsFailureRate(): boolean { + const totalRequests = this.#requests.total; + return totalRequests >= this.#minimumThroughput && + this.#failures.total / totalRequests >= this.#failureRateThreshold; + } + + /** + * Records a success. Closes the circuit from half-open once enough + * successes accrue. In closed state, a success can still be the request + * that lifts the window over `minimumThroughput`, so the rate is evaluated. + */ #handleSuccess(previousState: CircuitState): void { - if (previousState === "closed") return; + if (previousState === "closed") { + if (this.#state.state !== "closed" || !this.#exceedsFailureRate()) { + return; + } + this.#state = { + ...this.#state, + state: "open", + openedAt: Date.now(), + consecutiveSuccesses: 0, + }; + this.#onStateChange?.("closed", "open"); + this.#onOpen?.(this.#failures.total, this.#requests.total); + return; + } if (this.#state.state !== "half_open") return; const newSuccessCount = this.#state.consecutiveSuccesses + 1; diff --git a/async/unstable_circuit_breaker_test.ts b/async/unstable_circuit_breaker_test.ts index 4e5c71bc0c98..55cc92bb83bb 100644 --- a/async/unstable_circuit_breaker_test.ts +++ b/async/unstable_circuit_breaker_test.ts @@ -428,6 +428,36 @@ Deno.test("CircuitBreaker.execute() frees half_open concurrency slot on failure" assertEquals(breaker.state, "closed"); }); +Deno.test("CircuitBreaker.execute() opens when a success brings the window to minimumThroughput", async () => { + const opens: [number, number][] = []; + const changes: string[] = []; + const breaker = new CircuitBreaker({ + failureRateThreshold: 0.5, + minimumThroughput: 3, + onOpen: (failures, requests) => opens.push([failures, requests]), + onStateChange: (from, to) => changes.push(`${from}->${to}`), + }); + + await failN(breaker, 2); + assertEquals(breaker.state, "closed"); + + await succeedN(breaker, 1); + assertEquals(breaker.state, "open"); + assertEquals(opens, [[2, 3]]); + assertEquals(changes, ["closed->open"]); +}); + +Deno.test("CircuitBreaker.execute() stays closed when a success keeps the rate below the threshold", async () => { + const breaker = new CircuitBreaker({ + failureRateThreshold: 0.5, + minimumThroughput: 3, + }); + + await failN(breaker, 1); + await succeedN(breaker, 2); + assertEquals(breaker.state, "closed"); +}); + Deno.test("CircuitBreaker.execute() prevents stale half_open success from closing after concurrent failure", async () => { using time = new FakeTime();