From 0cf01a792cf0a9511c6cc2e8d2465bafef60cbd6 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Sun, 6 Sep 2026 09:47:01 +0200 Subject: [PATCH] fix(async/unstable): count circuit breaker requests at completion --- async/unstable_circuit_breaker.ts | 14 ++++-- async/unstable_circuit_breaker_test.ts | 62 ++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/async/unstable_circuit_breaker.ts b/async/unstable_circuit_breaker.ts index 38107103d8ab..b0902cb14367 100644 --- a/async/unstable_circuit_breaker.ts +++ b/async/unstable_circuit_breaker.ts @@ -582,13 +582,11 @@ export class CircuitBreaker { }; } - this.#rotateToNow(currentTime); - this.#requests.increment(); - let result: R; try { result = await fn(); } catch (error) { + this.#recordRequest(); if (tryCall(this.#isFailure, error)) { this.#handleFailure(error, currentState.state); } @@ -602,6 +600,7 @@ export class CircuitBreaker { } } + this.#recordRequest(); const isResultFail = tryCall(this.#isResultFailure, result); if (isResultFail) { this.#handleFailure(undefined, currentState.state); @@ -716,6 +715,15 @@ export class CircuitBreaker { } } + /** + * Counts a completed request. Requests are recorded on completion, in the + * same segment as their outcome, so failures can never outnumber requests. + */ + #recordRequest(): void { + this.#rotateToNow(Date.now()); + this.#requests.increment(); + } + /** Resets both counters and the rotation timestamp. */ #clearCounters(): void { this.#requests.clear(); diff --git a/async/unstable_circuit_breaker_test.ts b/async/unstable_circuit_breaker_test.ts index 4e5c71bc0c98..47fa26bc4f88 100644 --- a/async/unstable_circuit_breaker_test.ts +++ b/async/unstable_circuit_breaker_test.ts @@ -428,6 +428,68 @@ Deno.test("CircuitBreaker.execute() frees half_open concurrency slot on failure" assertEquals(breaker.state, "closed"); }); +Deno.test("CircuitBreaker.execute() counts a request in the same window segment as its outcome", async () => { + using time = new FakeTime(); + + const samples: [number, number][] = []; + const breaker = new CircuitBreaker({ + failureRateThreshold: 0.5, + minimumThroughput: 1, + windowMs: 1000, + segmentsPerWindow: 10, + onFailure: (_error, failures, requests) => + samples.push([failures, requests]), + }); + + const rejects: ((err: Error) => void)[] = []; + const pending = [0, 1].map(() => + breaker.execute( + () => + new Promise((_r, rej) => { + rejects.push(rej); + }), + ).catch(() => {}) + ); + + // The whole window expires, then a fresh request rotates the counters. + time.tick(1500); + await breaker.execute(() => Promise.resolve("ok")); + + for (const reject of rejects) reject(new Error("late")); + await Promise.all(pending); + + assertEquals(samples, [[1, 2], [2, 3]]); + assertEquals(breaker.state, "open"); +}); + +Deno.test("CircuitBreaker.execute() evicts expired history before evaluating a delayed failure", async () => { + using time = new FakeTime(); + + const breaker = new CircuitBreaker({ + failureRateThreshold: 0.5, + minimumThroughput: 2, + windowMs: 1000, + segmentsPerWindow: 10, + }); + + await failN(breaker, 1); + + let reject: ((err: Error) => void) | undefined; + const pending = breaker.execute( + () => + new Promise((_r, rej) => { + reject = rej; + }), + ).catch(() => {}); + + // The earlier failure falls out of the window before this one completes. + time.tick(5000); + reject?.(new Error("late")); + await pending; + + assertEquals(breaker.state, "closed"); +}); + Deno.test("CircuitBreaker.execute() prevents stale half_open success from closing after concurrent failure", async () => { using time = new FakeTime();