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>
This commit is contained in:
285
services/spamshield/test/circuit-breaker.test.ts
Normal file
285
services/spamshield/test/circuit-breaker.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
199
services/spamshield/test/spamshield.test.ts
Normal file
199
services/spamshield/test/spamshield.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SpamShieldService } from '../src/services/spamshield.service';
|
||||
import { spamConfig } from '../src/config/spamshield.config';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as unknown as typeof global.fetch;
|
||||
|
||||
describe('SpamShieldService', () => {
|
||||
let service: SpamShieldService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = SpamShieldService.getInstance();
|
||||
service.resetCircuits();
|
||||
mockFetch.mockReset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('checkReputation', () => {
|
||||
it('combines scores from both Hiya and Truecaller', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ spamScore: 0.8 }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ spamProbability: 0.9 }),
|
||||
} as Response);
|
||||
|
||||
const result = await service.checkReputation('+1234567890');
|
||||
|
||||
expect(result.source).toBe('combined');
|
||||
expect(result.score).toBeCloseTo(0.85, 2);
|
||||
expect(result.isSpam).toBe(true);
|
||||
expect(result.hiyaScore).toBe(0.8);
|
||||
expect(result.truecallerScore).toBe(0.9);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses Hiya score + Truecaller fallback when Truecaller API fails', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ spamScore: 0.6 }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
} as Response);
|
||||
|
||||
const result = await service.checkReputation('+1234567890');
|
||||
|
||||
expect(result.source).toBe('combined');
|
||||
expect(result.hiyaScore).toBe(0.6);
|
||||
expect(result.truecallerScore).toBe(0.5);
|
||||
});
|
||||
|
||||
it('uses Hiya fallback + Truecaller score when Hiya API fails', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ spamProbability: 0.3 }),
|
||||
} as Response);
|
||||
|
||||
const result = await service.checkReputation('+1234567890');
|
||||
|
||||
expect(result.source).toBe('combined');
|
||||
expect(result.hiyaScore).toBe(0.5);
|
||||
expect(result.truecallerScore).toBe(0.3);
|
||||
});
|
||||
|
||||
it('uses both fallbacks when both APIs fail', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
} as Response);
|
||||
|
||||
const result = await service.checkReputation('+1234567890');
|
||||
|
||||
expect(result.source).toBe('combined');
|
||||
expect(result.hiyaScore).toBe(0.5);
|
||||
expect(result.truecallerScore).toBe(0.5);
|
||||
expect(result.score).toBe(0.5);
|
||||
expect(result.isSpam).toBe(false);
|
||||
});
|
||||
|
||||
it('validates phone number length', async () => {
|
||||
const shortNumber = '123';
|
||||
const result = service.checkReputation(shortNumber);
|
||||
await expect(result).rejects.toThrow('Invalid phone number format');
|
||||
});
|
||||
|
||||
it('returns non-spam for low scores', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ spamScore: 0.2 }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ spamProbability: 0.1 }),
|
||||
} as Response);
|
||||
|
||||
const result = await service.checkReputation('+1234567890');
|
||||
|
||||
expect(result.isSpam).toBe(false);
|
||||
expect(result.score).toBeCloseTo(0.15, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('circuit breaker integration', () => {
|
||||
it('opens circuit after consecutive failures', async () => {
|
||||
const metricsBefore = service.getCircuitMetrics();
|
||||
expect(metricsBefore.hiya.state).toBe('CLOSED');
|
||||
|
||||
for (let i = 0; i < spamConfig.circuitBreakerThreshold; i++) {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
} as Response);
|
||||
await service.checkReputation('+1234567890');
|
||||
}
|
||||
|
||||
const metricsAfter = service.getCircuitMetrics();
|
||||
expect(metricsAfter.hiya.totalFailures).toBeGreaterThanOrEqual(spamConfig.circuitBreakerThreshold);
|
||||
expect(metricsAfter.truecaller.totalFailures).toBeGreaterThanOrEqual(spamConfig.circuitBreakerThreshold);
|
||||
});
|
||||
|
||||
it('exposes circuit metrics for monitoring', () => {
|
||||
const metrics = service.getCircuitMetrics();
|
||||
|
||||
expect(metrics.hiya).toHaveProperty('state', 'CLOSED');
|
||||
expect(metrics.hiya).toHaveProperty('failureCount');
|
||||
expect(metrics.hiya).toHaveProperty('successCount');
|
||||
expect(metrics.hiya).toHaveProperty('totalExecutions');
|
||||
expect(metrics.hiya).toHaveProperty('totalFailures');
|
||||
expect(metrics.hiya).toHaveProperty('totalSuccesses');
|
||||
expect(metrics.hiya).toHaveProperty('lastFailureTime');
|
||||
expect(metrics.hiya).toHaveProperty('lastSuccessTime');
|
||||
expect(metrics.hiya).toHaveProperty('stateChangedAt');
|
||||
|
||||
expect(metrics.truecaller).toHaveProperty('state', 'CLOSED');
|
||||
});
|
||||
|
||||
it('resets circuits to CLOSED state', () => {
|
||||
service.resetCircuits();
|
||||
const metrics = service.getCircuitMetrics();
|
||||
expect(metrics.hiya.state).toBe('CLOSED');
|
||||
expect(metrics.truecaller.state).toBe('CLOSED');
|
||||
});
|
||||
|
||||
it('returns fallback scores when circuits are open', async () => {
|
||||
service.resetCircuits();
|
||||
|
||||
for (let i = 0; i < spamConfig.circuitBreakerThreshold; i++) {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
} as Response);
|
||||
await service.checkReputation('+1234567890');
|
||||
}
|
||||
|
||||
const metrics = service.getCircuitMetrics();
|
||||
expect(metrics.hiya.state).toBe('OPEN');
|
||||
expect(metrics.truecaller.state).toBe('OPEN');
|
||||
|
||||
const result = await service.checkReputation('+1234567890');
|
||||
expect(result.hiyaScore).toBe(0.5);
|
||||
expect(result.truecallerScore).toBe(0.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user