Files
ShieldAI/services/spamshield/test/circuit-breaker.test.ts
Michael Freno 3ad030a412 Add circuit breaker for Hiya/Truecaller external APIs (FRE-4508)
- Implement CircuitBreaker class with CLOSED/OPEN/HALF_OPEN states
- Configurable failure threshold, success threshold, and timeout
- Fallback behavior when circuit opens (returns neutral 0.5 score)
- State change callbacks for monitoring and logging
- Comprehensive metrics tracking (executions, failures, successes, timestamps)
- Update SpamShieldService to use circuit breakers for both Hiya and Truecaller
- Add parallel API calls with graceful degradation
- Export circuit breaker types and service interfaces
- 32 unit tests covering circuit transitions, fallback, and service integration

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 19:07:54 -04:00

286 lines
9.3 KiB
TypeScript

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<T>(breaker: CircuitBreaker, fn: () => Promise<T>, fallback?: () => T): Promise<T | Error> {
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');
});
});
});