Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions async/unstable_circuit_breaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,13 +582,11 @@ export class CircuitBreaker<T = unknown> {
};
}

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);
}
Expand All @@ -602,6 +600,7 @@ export class CircuitBreaker<T = unknown> {
}
}

this.#recordRequest();
const isResultFail = tryCall(this.#isResultFailure, result);
if (isResultFail) {
this.#handleFailure(undefined, currentState.state);
Expand Down Expand Up @@ -716,6 +715,15 @@ export class CircuitBreaker<T = unknown> {
}
}

/**
* 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();
Expand Down
62 changes: 62 additions & 0 deletions async/unstable_circuit_breaker_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>((_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<string>((_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();

Expand Down
Loading