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
49 changes: 41 additions & 8 deletions async/unstable_circuit_breaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ export interface CircuitBreakerOptions<T> {
onClose?: () => void;
}

/** Options for {@linkcode CircuitBreaker.execute}. */
export interface CircuitBreakerExecuteOptions {
/**
* An optional abort signal that can be used to cancel the operation
* before it starts. If the signal is already aborted when `execute` is
* called, the operation will fail immediately without executing the function.
*
* Note: This only checks the abort status before execution. It does not
* interrupt an in-progress operation — pass the signal to your async
* function for that behavior.
*/
signal?: AbortSignal;
}

/** Statistics returned by {@linkcode CircuitBreaker.getStats}. */
export interface CircuitBreakerStats {
/** Current state of the circuit breaker. */
Expand Down Expand Up @@ -262,16 +276,19 @@ function pruneOldFailures(
* });
* ```
*
* @example Composing with retry
* @example Composing with retry and AbortSignal
* ```ts ignore
* import { retry } from "@std/async/retry";
* import { CircuitBreaker } from "@std/async/unstable-circuit-breaker";
*
* const breaker = new CircuitBreaker({ failureThreshold: 5 });
*
* // Circuit breaker wraps retry - if service is down, fail fast
* const result = await breaker.execute(() =>
* retry(() => fetch("https://api.example.com"), { maxAttempts: 3 })
* // Timeout applies to the entire operation (circuit breaker + retries)
* const signal = AbortSignal.timeout(5000);
*
* const result = await breaker.execute(
* () => retry(() => fetch("https://api.example.com"), { signal }),
* { signal },
* );
* ```
*
Expand Down Expand Up @@ -429,13 +446,24 @@ export class CircuitBreaker<T = unknown> {
* returned as a promise.
*
* @example Usage with async function
* ```ts
* ```ts ignore
* import { CircuitBreaker } from "@std/async/unstable-circuit-breaker";
* import { assertEquals } from "@std/assert";
*
* const breaker = new CircuitBreaker({ failureThreshold: 5 });
* const result = await breaker.execute(() => Promise.resolve("success"));
* assertEquals(result, "success");
* const response = await breaker.execute(() => fetch("https://api.example.com"));
* ```
*
* @example With timeout
* ```ts ignore
* import { CircuitBreaker } from "@std/async/unstable-circuit-breaker";
*
* const breaker = new CircuitBreaker({ failureThreshold: 5 });
*
* // Abort if operation takes longer than 5 seconds
* const response = await breaker.execute(
* () => fetch("https://api.example.com"),
* { signal: AbortSignal.timeout(5000) },
* );
* ```
*
* @example Usage with sync function
Expand All @@ -450,12 +478,17 @@ export class CircuitBreaker<T = unknown> {
*
* @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.
* @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.
*/
async execute<R extends T>(
fn: (() => Promise<R>) | (() => R),
options?: CircuitBreakerExecuteOptions,
): Promise<R> {
options?.signal?.throwIfAborted();

const currentTime = Date.now();
const currentState = this.#resolveCurrentState();

Expand Down
47 changes: 47 additions & 0 deletions async/unstable_circuit_breaker_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -923,3 +923,50 @@ Deno.test("CircuitBreaker.execute() handles sync function that throws", async ()
assertEquals(breaker.failureCount, 1);
assertEquals(breaker.state, "open");
});

Deno.test("CircuitBreaker.execute() rejects immediately if signal already aborted", async () => {
const breaker = new CircuitBreaker();
const controller = new AbortController();
controller.abort();

let fnCalled = false;
await assertRejects(
() =>
breaker.execute(() => {
fnCalled = true;
return Promise.resolve("ignored");
}, { signal: controller.signal }),
DOMException,
"aborted",
);

// Function should never have been called
assertEquals(fnCalled, false);
// Circuit should remain closed (no failure recorded)
assertEquals(breaker.state, "closed");
assertEquals(breaker.failureCount, 0);
});

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.failureCount, 0);
assertEquals(breaker.state, "closed");
});
Loading