222 lines
5.7 KiB
TypeScript
222 lines
5.7 KiB
TypeScript
/**
|
|
* Tests for CircuitBreaker — state machine (CLOSED → OPEN → HALF_OPEN).
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import { CircuitBreaker } from './circuit-breaker.js';
|
|
|
|
describe('CircuitBreaker', () => {
|
|
let cb: CircuitBreaker;
|
|
|
|
beforeEach(() => {
|
|
cb = new CircuitBreaker({ failureThreshold: 3, resetTimeoutMs: 1000 });
|
|
});
|
|
|
|
describe('initial state', () => {
|
|
it('starts in CLOSED state', () => {
|
|
expect(cb.currentState).toBe('CLOSED');
|
|
});
|
|
|
|
it('allows requests when CLOSED', () => {
|
|
expect(cb.allowRequest()).toBe(true);
|
|
});
|
|
|
|
it('stats reflect initial state', () => {
|
|
const stats = cb.stats;
|
|
expect(stats.state).toBe('CLOSED');
|
|
expect(stats.failureCount).toBe(0);
|
|
expect(stats.threshold).toBe(3);
|
|
expect(stats.resetMs).toBe(1000);
|
|
});
|
|
});
|
|
|
|
describe('default options', () => {
|
|
it('uses failureThreshold=5 and resetTimeoutMs=30000 by default', () => {
|
|
const defaultCb = new CircuitBreaker();
|
|
const stats = defaultCb.stats;
|
|
expect(stats.threshold).toBe(5);
|
|
expect(stats.resetMs).toBe(30_000);
|
|
});
|
|
});
|
|
|
|
describe('failure tracking', () => {
|
|
it('stays CLOSED below threshold', () => {
|
|
cb.recordFailure();
|
|
expect(cb.currentState).toBe('CLOSED');
|
|
expect(cb.stats.failureCount).toBe(1);
|
|
|
|
cb.recordFailure();
|
|
expect(cb.currentState).toBe('CLOSED');
|
|
expect(cb.stats.failureCount).toBe(2);
|
|
});
|
|
|
|
it('transitions to OPEN at threshold', () => {
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
|
|
expect(cb.currentState).toBe('OPEN');
|
|
expect(cb.stats.failureCount).toBe(3);
|
|
});
|
|
|
|
it('blocks requests when OPEN', () => {
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
|
|
expect(cb.allowRequest()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('success resets', () => {
|
|
it('resets failure count on success', () => {
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
expect(cb.stats.failureCount).toBe(2);
|
|
|
|
cb.recordSuccess();
|
|
expect(cb.stats.failureCount).toBe(0);
|
|
expect(cb.currentState).toBe('CLOSED');
|
|
});
|
|
|
|
it('returns to CLOSED from any state on success', () => {
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
expect(cb.currentState).toBe('OPEN');
|
|
|
|
cb.recordSuccess();
|
|
expect(cb.currentState).toBe('CLOSED');
|
|
expect(cb.allowRequest()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('HALF_OPEN transition', () => {
|
|
it('transitions to HALF_OPEN after reset timeout', () => {
|
|
vi.useFakeTimers();
|
|
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
expect(cb.currentState).toBe('OPEN');
|
|
expect(cb.allowRequest()).toBe(false);
|
|
|
|
// Advance past reset timeout
|
|
vi.advanceTimersByTime(1001);
|
|
|
|
// allowRequest should transition to HALF_OPEN
|
|
expect(cb.allowRequest()).toBe(true);
|
|
expect(cb.currentState).toBe('HALF_OPEN');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('returns to CLOSED on success during HALF_OPEN', () => {
|
|
vi.useFakeTimers();
|
|
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
|
|
vi.advanceTimersByTime(1001);
|
|
cb.allowRequest(); // transitions to HALF_OPEN
|
|
|
|
cb.recordSuccess();
|
|
expect(cb.currentState).toBe('CLOSED');
|
|
expect(cb.stats.failureCount).toBe(0);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('returns to OPEN on failure during HALF_OPEN', () => {
|
|
vi.useFakeTimers();
|
|
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
|
|
vi.advanceTimersByTime(1001);
|
|
cb.allowRequest(); // transitions to HALF_OPEN
|
|
|
|
// Failure during HALF_OPEN should re-open
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
expect(cb.currentState).toBe('OPEN');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('allows probe request in HALF_OPEN state', () => {
|
|
vi.useFakeTimers();
|
|
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
|
|
vi.advanceTimersByTime(1001);
|
|
expect(cb.allowRequest()).toBe(true); // probe allowed
|
|
expect(cb.currentState).toBe('HALF_OPEN');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe('OPEN state timing', () => {
|
|
it('stays OPEN before reset timeout', () => {
|
|
vi.useFakeTimers();
|
|
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
|
|
vi.advanceTimersByTime(500); // Half of reset timeout
|
|
expect(cb.allowRequest()).toBe(false);
|
|
expect(cb.currentState).toBe('OPEN');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('transitions exactly at reset timeout boundary', () => {
|
|
vi.useFakeTimers();
|
|
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
cb.recordFailure();
|
|
|
|
vi.advanceTimersByTime(1000); // Exactly at boundary
|
|
expect(cb.allowRequest()).toBe(true);
|
|
expect(cb.currentState).toBe('HALF_OPEN');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('handles rapid success/failure alternation', () => {
|
|
cb.recordFailure();
|
|
cb.recordSuccess();
|
|
cb.recordFailure();
|
|
cb.recordSuccess();
|
|
cb.recordFailure();
|
|
|
|
expect(cb.currentState).toBe('CLOSED');
|
|
expect(cb.stats.failureCount).toBe(1);
|
|
});
|
|
|
|
it('threshold of 1 opens immediately on first failure', () => {
|
|
const strictCb = new CircuitBreaker({ failureThreshold: 1 });
|
|
strictCb.recordFailure();
|
|
expect(strictCb.currentState).toBe('OPEN');
|
|
});
|
|
|
|
it('multiple successes keep state CLOSED', () => {
|
|
cb.recordSuccess();
|
|
cb.recordSuccess();
|
|
cb.recordSuccess();
|
|
expect(cb.currentState).toBe('CLOSED');
|
|
expect(cb.stats.failureCount).toBe(0);
|
|
});
|
|
});
|
|
});
|