Skip to main content
Glama
circuit-breaker.test.ts12 kB
/** * @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(); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sapientpants/deepsource-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server