import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CircuitBreaker, CircuitBreakerError, CircuitState } from '../src/circuit-breaker'; const fail = async () => { throw new Error('fail'); }; const success = async () => 'ok'; async function executeOrFail(breaker: CircuitBreaker, fn: () => Promise, fallback?: () => T): Promise { try { return await breaker.execute(fn, fallback); } catch (e) { return e as Error; } } describe('CircuitBreaker', () => { let stateChanges: Array<{ state: CircuitState; previous: CircuitState }>; beforeEach(() => { stateChanges = []; }); describe('initial state', () => { it('starts as CLOSED', () => { const breaker = new CircuitBreaker(); expect(breaker.getState()).toBe('CLOSED'); }); it('uses default thresholds', () => { const breaker = new CircuitBreaker(); const metrics = breaker.getMetrics(); expect(metrics.failureCount).toBe(0); expect(metrics.successCount).toBe(0); expect(metrics.totalExecutions).toBe(0); }); }); describe('custom configuration', () => { it('accepts custom failure threshold', async () => { const breaker = new CircuitBreaker({ failureThreshold: 3 }); for (let i = 0; i < 3; i++) { await executeOrFail(breaker, fail); } expect(breaker.getState()).toBe('OPEN'); }); it('accepts custom timeout', () => { const breaker = new CircuitBreaker({ timeout: 1000 }); expect(breaker.getState()).toBe('CLOSED'); }); it('calls onStateChange callback on transitions', async () => { const breaker = new CircuitBreaker({ failureThreshold: 2, onStateChange: (state, previous) => { stateChanges.push({ state, previous }); }, }); await executeOrFail(breaker, fail); await executeOrFail(breaker, fail); expect(stateChanges).toHaveLength(1); expect(stateChanges[0]).toEqual({ state: 'OPEN', previous: 'CLOSED' }); }); }); describe('state transitions', () => { it('transitions to OPEN after reaching failure threshold', async () => { const breaker = new CircuitBreaker({ failureThreshold: 3 }); await executeOrFail(breaker, fail); await executeOrFail(breaker, fail); expect(breaker.getState()).toBe('CLOSED'); await executeOrFail(breaker, fail); expect(breaker.getState()).toBe('OPEN'); }); it('transitions from OPEN to HALF_OPEN after timeout', async () => { const breaker = new CircuitBreaker({ failureThreshold: 2, timeout: 200, }); await executeOrFail(breaker, fail); await executeOrFail(breaker, fail); expect(breaker.getState()).toBe('OPEN'); await new Promise((resolve) => setTimeout(resolve, 250)); expect(breaker.getState()).toBe('HALF_OPEN'); }); it('transitions from HALF_OPEN to CLOSED after success threshold', async () => { const breaker = new CircuitBreaker({ failureThreshold: 2, successThreshold: 3, timeout: 100, }); await executeOrFail(breaker, fail); await executeOrFail(breaker, fail); expect(breaker.getState()).toBe('OPEN'); await new Promise((resolve) => setTimeout(resolve, 150)); const r1 = await breaker.execute(success); const r2 = await breaker.execute(success); expect(r1).toBe('ok'); expect(r2).toBe('ok'); expect(breaker.getState()).toBe('HALF_OPEN'); const r3 = await breaker.execute(success); expect(r3).toBe('ok'); expect(breaker.getState()).toBe('CLOSED'); }); it('transitions from HALF_OPEN back to OPEN on failure', async () => { const breaker = new CircuitBreaker({ failureThreshold: 2, timeout: 100, }); await executeOrFail(breaker, fail); await executeOrFail(breaker, fail); await new Promise((resolve) => setTimeout(resolve, 150)); expect(breaker.getState()).toBe('HALF_OPEN'); await executeOrFail(breaker, fail); expect(breaker.getState()).toBe('OPEN'); }); }); describe('execute with fallback', () => { it('returns fallback value when circuit is OPEN', async () => { const breaker = new CircuitBreaker({ failureThreshold: 2 }); await executeOrFail(breaker, fail); await executeOrFail(breaker, fail); const result = await breaker.execute( async () => { throw new Error('should not reach'); }, () => 'fallback-value' ); expect(result).toBe('fallback-value'); }); it('returns fallback value when API throws in OPEN state', async () => { const breaker = new CircuitBreaker({ failureThreshold: 1 }); await executeOrFail(breaker, fail); const originalFn = vi.fn(() => { throw new Error('api error'); }); const fallbackFn = vi.fn(() => 0.5); const result = await breaker.execute(originalFn, fallbackFn); expect(result).toBe(0.5); expect(fallbackFn).toHaveBeenCalled(); }); it('executes function normally when circuit is CLOSED', async () => { const breaker = new CircuitBreaker({ failureThreshold: 3 }); const fn = vi.fn(async () => 'success'); const result = await breaker.execute(fn, () => 'fallback'); expect(result).toBe('success'); expect(fn).toHaveBeenCalled(); }); it('uses fallback when circuit is OPEN', async () => { const breaker = new CircuitBreaker({ failureThreshold: 1 }); await executeOrFail(breaker, fail); const fn = vi.fn(async () => 'original'); const fallback = vi.fn(() => 'fallback-value'); const result = await breaker.execute(fn, fallback); expect(result).toBe('fallback-value'); expect(fallback).toHaveBeenCalled(); }); it('throws CircuitBreakerError when OPEN and no fallback', async () => { const breaker = new CircuitBreaker({ failureThreshold: 1 }); await executeOrFail(breaker, fail); const result = await executeOrFail(breaker, async () => 'value'); expect(result).toBeInstanceOf(CircuitBreakerError); expect((result as CircuitBreakerError).state).toBe('OPEN'); }); it('throws original error when fallback also fails in CLOSED state', async () => { const breaker = new CircuitBreaker({ failureThreshold: 5 }); const originalError = new Error('api error'); const result = await executeOrFail( breaker, async () => { throw originalError; }, () => { throw new Error('fallback error'); } ); expect(result).toBe(originalError); }); }); describe('metrics', () => { it('tracks total executions', async () => { const breaker = new CircuitBreaker(); await breaker.execute(success); await breaker.execute(success); const metrics = breaker.getMetrics(); expect(metrics.totalExecutions).toBe(2); expect(metrics.totalSuccesses).toBe(2); expect(metrics.totalFailures).toBe(0); }); it('tracks failures', async () => { const breaker = new CircuitBreaker({ failureThreshold: 5 }); await executeOrFail(breaker, fail); const metrics = breaker.getMetrics(); expect(metrics.totalExecutions).toBe(1); expect(metrics.totalFailures).toBe(1); expect(metrics.failureCount).toBe(1); }); it('includes state change timestamp', () => { const breaker = new CircuitBreaker(); const metrics = breaker.getMetrics(); expect(metrics.stateChangedAt).toBeDefined(); expect(metrics.stateChangedAt!.getTime()).toBeGreaterThan(Date.now() - 1000); }); it('tracks last failure and success times', async () => { const breaker = new CircuitBreaker({ failureThreshold: 5 }); const before = Date.now(); await breaker.execute(success); await new Promise((resolve) => setTimeout(resolve, 10)); await executeOrFail(breaker, fail); const metrics = breaker.getMetrics(); expect(metrics.lastSuccessTime!.getTime()).toBeGreaterThanOrEqual(before); expect(metrics.lastFailureTime!.getTime()).toBeGreaterThanOrEqual(metrics.lastSuccessTime!.getTime()); }); }); describe('reset', () => { it('resets circuit to CLOSED state', async () => { const breaker = new CircuitBreaker({ failureThreshold: 2 }); await executeOrFail(breaker, fail); await executeOrFail(breaker, fail); expect(breaker.getState()).toBe('OPEN'); breaker.reset(); expect(breaker.getState()).toBe('CLOSED'); const metrics = breaker.getMetrics(); expect(metrics.failureCount).toBe(0); expect(metrics.successCount).toBe(0); }); it('allows execution after reset', async () => { const breaker = new CircuitBreaker({ failureThreshold: 1 }); await executeOrFail(breaker, fail); breaker.reset(); const result = await breaker.execute(success); expect(result).toBe('ok'); }); }); describe('CircuitBreakerError', () => { it('includes circuit state in error', async () => { const breaker = new CircuitBreaker({ failureThreshold: 1 }); await executeOrFail(breaker, fail); const result = await executeOrFail(breaker, success); expect(result).toBeInstanceOf(CircuitBreakerError); expect((result as CircuitBreakerError).state).toBe('OPEN'); expect((result as CircuitBreakerError).message).toContain('OPEN'); }); }); });