/**
* @vitest-environment node
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
CircuitBreaker,
CircuitBreakerManager,
CircuitState,
CircuitBreakerConfig,
} from '../../../utils/retry/circuit-breaker.js';
describe('Circuit Breaker', () => {
let breaker: CircuitBreaker;
const testConfig: Partial<CircuitBreakerConfig> = {
failureThreshold: 3,
failureWindow: 1000,
recoveryTimeout: 500,
successThreshold: 2,
halfOpenMaxAttempts: 3,
};
beforeEach(() => {
breaker = new CircuitBreaker('test-endpoint', testConfig);
});
describe('State transitions', () => {
it('should start in closed state', () => {
expect(breaker.getState()).toBe(CircuitState.CLOSED);
expect(breaker.canRequest()).toBe(true);
});
it('should open after failure threshold', () => {
// Record failures up to threshold
for (let i = 0; i < 3; i++) {
expect(breaker.getState()).toBe(CircuitState.CLOSED);
breaker.recordFailure();
}
// Should now be open
expect(breaker.getState()).toBe(CircuitState.OPEN);
expect(breaker.canRequest()).toBe(false);
});
it('should transition to half-open after recovery timeout', async () => {
// Open the circuit
for (let i = 0; i < 3; i++) {
breaker.recordFailure();
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
// Wait for recovery timeout
await new Promise((resolve) => setTimeout(resolve, 600));
// Should transition to half-open on next request check
expect(breaker.canRequest()).toBe(true);
expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
});
it('should close from half-open after success threshold', async () => {
// Open the circuit
for (let i = 0; i < 3; i++) {
breaker.recordFailure();
}
// Wait for recovery timeout
await new Promise((resolve) => setTimeout(resolve, 600));
breaker.canRequest(); // Transition to half-open
// Record successes
breaker.recordSuccess();
expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
breaker.recordSuccess();
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
it('should reopen from half-open on failure', async () => {
// Open the circuit
for (let i = 0; i < 3; i++) {
breaker.recordFailure();
}
// Wait for recovery timeout
await new Promise((resolve) => setTimeout(resolve, 600));
breaker.canRequest(); // Transition to half-open
expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
// Any failure in half-open reopens
breaker.recordFailure();
expect(breaker.getState()).toBe(CircuitState.OPEN);
});
});
describe('Request limiting', () => {
it('should block requests when open', () => {
// Open the circuit
for (let i = 0; i < 3; i++) {
breaker.recordFailure();
}
expect(breaker.canRequest()).toBe(false);
expect(breaker.canRequest()).toBe(false);
expect(breaker.canRequest()).toBe(false);
});
it('should limit requests in half-open state', async () => {
// Open the circuit
for (let i = 0; i < 3; i++) {
breaker.recordFailure();
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
// Wait for recovery timeout
await new Promise((resolve) => setTimeout(resolve, 600));
// First canRequest should transition to half-open and return true
expect(breaker.canRequest()).toBe(true);
expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
// We should be able to make maxAttempts-1 more requests (since we just made one)
expect(breaker.canRequest()).toBe(true); // 2nd attempt
expect(breaker.canRequest()).toBe(true); // 3rd attempt
// After reaching max attempts, should return false
expect(breaker.canRequest()).toBe(false); // 4th attempt exceeds limit
});
});
describe('Time window management', () => {
it('should only count failures within time window', async () => {
// Record 2 failures
breaker.recordFailure();
breaker.recordFailure();
expect(breaker.getState()).toBe(CircuitState.CLOSED);
// Wait for window to expire
await new Promise((resolve) => setTimeout(resolve, 1100));
// These failures are outside the window, so count resets
breaker.recordFailure();
expect(breaker.getState()).toBe(CircuitState.CLOSED);
breaker.recordFailure();
breaker.recordFailure();
expect(breaker.getState()).toBe(CircuitState.OPEN);
});
it('should clean up old entries', async () => {
const stats1 = breaker.getStats();
expect(stats1.failureCount).toBe(0);
breaker.recordFailure();
breaker.recordSuccess();
const stats2 = breaker.getStats();
expect(stats2.failureCount).toBe(1);
expect(stats2.successCount).toBe(1);
// Wait for window to expire
await new Promise((resolve) => setTimeout(resolve, 1100));
const stats3 = breaker.getStats();
expect(stats3.failureCount).toBe(0);
expect(stats3.successCount).toBe(0);
});
});
describe('Statistics', () => {
it('should track statistics correctly', () => {
breaker.recordSuccess();
breaker.recordSuccess();
breaker.recordFailure();
const stats = breaker.getStats();
expect(stats.state).toBe(CircuitState.CLOSED);
expect(stats.successCount).toBe(2);
expect(stats.failureCount).toBe(1);
expect(stats.totalRequests).toBe(3);
expect(stats.successRate).toBeCloseTo(66.67, 1);
});
it('should include timestamps in statistics', () => {
const stats1 = breaker.getStats();
expect(stats1.lastCloseTime).toBeDefined();
expect(stats1.lastOpenTime).toBeUndefined();
// Open the circuit
for (let i = 0; i < 3; i++) {
breaker.recordFailure();
}
const stats2 = breaker.getStats();
expect(stats2.lastOpenTime).toBeDefined();
expect(stats2.lastCloseTime).toBeUndefined();
});
});
describe('Reset functionality', () => {
it('should reset to initial state', () => {
// Put breaker in open state with some history
for (let i = 0; i < 5; i++) {
breaker.recordFailure();
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
// Reset
breaker.reset();
expect(breaker.getState()).toBe(CircuitState.CLOSED);
expect(breaker.canRequest()).toBe(true);
const stats = breaker.getStats();
expect(stats.failureCount).toBe(0);
expect(stats.successCount).toBe(0);
});
});
describe('Edge cases', () => {
it('should handle success in open state gracefully', () => {
// Open the circuit
for (let i = 0; i < 3; i++) {
breaker.recordFailure();
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
// This shouldn't happen in practice, but handle gracefully
breaker.recordSuccess();
expect(breaker.getState()).toBe(CircuitState.OPEN);
});
it('should handle rapid state checks', () => {
for (let i = 0; i < 100; i++) {
breaker.canRequest();
}
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
});
});
describe('CircuitBreakerManager', () => {
let manager: CircuitBreakerManager;
beforeEach(() => {
manager = CircuitBreakerManager.getInstance();
manager.clear();
});
describe('Singleton pattern', () => {
it('should return the same instance', () => {
const instance1 = CircuitBreakerManager.getInstance();
const instance2 = CircuitBreakerManager.getInstance();
expect(instance1).toBe(instance2);
});
});
describe('Breaker management', () => {
it('should create and retrieve breakers', () => {
const breaker1 = manager.getBreaker('endpoint1');
const breaker2 = manager.getBreaker('endpoint2');
const breaker1Again = manager.getBreaker('endpoint1');
expect(breaker1).not.toBe(breaker2);
expect(breaker1).toBe(breaker1Again);
});
it('should accept custom configuration', () => {
const breaker = manager.getBreaker('custom', {
failureThreshold: 10,
recoveryTimeout: 1000,
});
// Verify it uses custom config by not opening after 3 failures
for (let i = 0; i < 3; i++) {
breaker.recordFailure();
}
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
});
describe('Global operations', () => {
it('should get all statistics', () => {
const breaker1 = manager.getBreaker('endpoint1');
const breaker2 = manager.getBreaker('endpoint2');
breaker1.recordSuccess();
breaker2.recordFailure();
const allStats = manager.getAllStats();
expect(allStats.size).toBe(2);
expect(allStats.get('endpoint1')?.successCount).toBe(1);
expect(allStats.get('endpoint2')?.failureCount).toBe(1);
});
it('should reset all breakers', () => {
const breaker1 = manager.getBreaker('endpoint1');
const breaker2 = manager.getBreaker('endpoint2');
// Open both breakers
for (let i = 0; i < 5; i++) {
breaker1.recordFailure();
breaker2.recordFailure();
}
expect(breaker1.getState()).toBe(CircuitState.OPEN);
expect(breaker2.getState()).toBe(CircuitState.OPEN);
// Reset all
manager.resetAll();
expect(breaker1.getState()).toBe(CircuitState.CLOSED);
expect(breaker2.getState()).toBe(CircuitState.CLOSED);
});
it('should clear all breakers', () => {
manager.getBreaker('endpoint1');
manager.getBreaker('endpoint2');
const stats1 = manager.getAllStats();
expect(stats1.size).toBe(2);
manager.clear();
const stats2 = manager.getAllStats();
expect(stats2.size).toBe(0);
});
});
describe('Environment variable configuration', () => {
beforeEach(() => {
vi.stubEnv('CIRCUIT_BREAKER_THRESHOLD', '');
vi.stubEnv('CIRCUIT_BREAKER_TIMEOUT_MS', '');
});
it('should use environment variables when set', () => {
vi.stubEnv('CIRCUIT_BREAKER_THRESHOLD', '10');
vi.stubEnv('CIRCUIT_BREAKER_TIMEOUT_MS', '5000');
// Create a new circuit breaker directly with custom config
const breaker = new CircuitBreaker('env-test', {
failureThreshold: parseInt(process.env.CIRCUIT_BREAKER_THRESHOLD || '5', 10),
failureWindow: 60000,
recoveryTimeout: parseInt(process.env.CIRCUIT_BREAKER_TIMEOUT_MS || '30000', 10),
successThreshold: 3,
halfOpenMaxAttempts: 5,
});
// Should not open after 5 failures (threshold from env is 10)
for (let i = 0; i < 5; i++) {
breaker.recordFailure();
}
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
});
describe('Default case handling', () => {
it('should handle unexpected state in recordSuccess', () => {
const breaker = new CircuitBreaker('test-unexpected-success');
// Force an unexpected state by manipulating the internal state
// @ts-expect-error - Accessing private property for testing
// skipcq: JS-0323 - Intentionally using any to force invalid state for testing
breaker['state'] = 'unexpected' as any;
// Should not throw, just log error
expect(() => breaker.recordSuccess()).not.toThrow();
});
it('should handle unexpected state in recordFailure', () => {
const breaker = new CircuitBreaker('test-unexpected-failure');
// Force an unexpected state by manipulating the internal state
// @ts-expect-error - Accessing private property for testing
// skipcq: JS-0323 - Intentionally using any to force invalid state for testing
breaker['state'] = 'unexpected' as any;
// Should not throw, just log error
expect(() => breaker.recordFailure()).not.toThrow();
});
});
});