plan/react-rewrite #1
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { CircuitBreaker } from "./circuit-breaker.js";
|
||||
|
||||
describe("CircuitBreaker", () => {
|
||||
let cb: CircuitBreaker;
|
||||
|
||||
beforeEach(() => {
|
||||
cb = new CircuitBreaker({ failureThreshold: 3, openDurationMs: 1000 });
|
||||
});
|
||||
|
||||
it("starts in closed state", () => {
|
||||
expect(cb.state).toBe("closed");
|
||||
});
|
||||
|
||||
it("passes through successful calls in closed state", async () => {
|
||||
const result = await cb.exec(() => Promise.resolve("ok"));
|
||||
expect(result).toBe("ok");
|
||||
expect(cb.state).toBe("closed");
|
||||
});
|
||||
|
||||
it("transitions to open after failureThreshold consecutive failures", async () => {
|
||||
const fail = () => Promise.reject(new Error("fail"));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(cb.exec(fail)).rejects.toThrow("fail");
|
||||
}
|
||||
expect(cb.state).toBe("open");
|
||||
});
|
||||
|
||||
it("rejects immediately in open state without calling the function", async () => {
|
||||
const fail = () => Promise.reject(new Error("fail"));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(cb.exec(fail)).rejects.toThrow();
|
||||
}
|
||||
|
||||
const fn = vi.fn(() => Promise.resolve("should not run"));
|
||||
await expect(cb.exec(fn)).rejects.toThrow(/circuit.*open/i);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("transitions to half-open after openDurationMs elapses", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fail = () => Promise.reject(new Error("fail"));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(cb.exec(fail)).rejects.toThrow();
|
||||
}
|
||||
expect(cb.state).toBe("open");
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(cb.state).toBe("half-open");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("transitions from half-open to closed on success", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fail = () => Promise.reject(new Error("fail"));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(cb.exec(fail)).rejects.toThrow();
|
||||
}
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(cb.state).toBe("half-open");
|
||||
|
||||
const result = await cb.exec(() => Promise.resolve("recovered"));
|
||||
expect(result).toBe("recovered");
|
||||
expect(cb.state).toBe("closed");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("transitions from half-open to open on failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fail = () => Promise.reject(new Error("fail"));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(cb.exec(fail)).rejects.toThrow();
|
||||
}
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(cb.state).toBe("half-open");
|
||||
|
||||
await expect(cb.exec(fail)).rejects.toThrow("fail");
|
||||
expect(cb.state).toBe("open");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resets failure count on a successful call in closed state", async () => {
|
||||
const fail = () => Promise.reject(new Error("fail"));
|
||||
await expect(cb.exec(fail)).rejects.toThrow();
|
||||
await expect(cb.exec(fail)).rejects.toThrow();
|
||||
// 2 failures, threshold is 3
|
||||
await cb.exec(() => Promise.resolve("ok"));
|
||||
// count should have reset, so 2 more failures should not open
|
||||
await expect(cb.exec(fail)).rejects.toThrow();
|
||||
await expect(cb.exec(fail)).rejects.toThrow();
|
||||
expect(cb.state).toBe("closed");
|
||||
});
|
||||
|
||||
it("reset() returns to closed state", async () => {
|
||||
const fail = () => Promise.reject(new Error("fail"));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(cb.exec(fail)).rejects.toThrow();
|
||||
}
|
||||
expect(cb.state).toBe("open");
|
||||
cb.reset();
|
||||
expect(cb.state).toBe("closed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
export interface CircuitBreakerOptions {
|
||||
failureThreshold?: number;
|
||||
openDurationMs?: number;
|
||||
}
|
||||
|
||||
type State = "closed" | "open" | "half-open";
|
||||
|
||||
export class CircuitBreaker {
|
||||
private readonly failureThreshold: number;
|
||||
private readonly openDurationMs: number;
|
||||
private failures = 0;
|
||||
private lastFailureTime = 0;
|
||||
private _state: State = "closed";
|
||||
|
||||
constructor(options?: CircuitBreakerOptions) {
|
||||
this.failureThreshold = options?.failureThreshold ?? 5;
|
||||
this.openDurationMs = options?.openDurationMs ?? 30_000;
|
||||
}
|
||||
|
||||
get state(): State {
|
||||
if (this._state === "open") {
|
||||
const elapsed = Date.now() - this.lastFailureTime;
|
||||
if (elapsed >= this.openDurationMs) {
|
||||
this._state = "half-open";
|
||||
}
|
||||
}
|
||||
return this._state;
|
||||
}
|
||||
|
||||
async exec<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const currentState = this.state;
|
||||
|
||||
if (currentState === "open") {
|
||||
throw new Error("Circuit breaker is open — request rejected");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
this.onSuccess();
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.onFailure();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._state = "closed";
|
||||
this.failures = 0;
|
||||
this.lastFailureTime = 0;
|
||||
}
|
||||
|
||||
private onSuccess(): void {
|
||||
this._state = "closed";
|
||||
this.failures = 0;
|
||||
}
|
||||
|
||||
private onFailure(): void {
|
||||
this.failures++;
|
||||
this.lastFailureTime = Date.now();
|
||||
if (this._state === "half-open" || this.failures >= this.failureThreshold) {
|
||||
this._state = "open";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user