plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
2 changed files with 168 additions and 0 deletions
Showing only changes of commit fd62d6f123 - Show all commits
+103
View File
@@ -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");
});
});
+65
View File
@@ -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";
}
}
}