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:
173
services/spamshield/src/circuit-breaker/circuit-breaker.ts
Normal file
173
services/spamshield/src/circuit-breaker/circuit-breaker.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
export type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';
|
||||||
|
|
||||||
|
export interface CircuitBreakerMetrics {
|
||||||
|
state: CircuitState;
|
||||||
|
failureCount: number;
|
||||||
|
successCount: number;
|
||||||
|
lastFailureTime: Date | null;
|
||||||
|
lastSuccessTime: Date | null;
|
||||||
|
stateChangedAt: Date | null;
|
||||||
|
totalExecutions: number;
|
||||||
|
totalFailures: number;
|
||||||
|
totalSuccesses: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitBreakerOptions {
|
||||||
|
failureThreshold?: number;
|
||||||
|
successThreshold?: number;
|
||||||
|
timeout?: number;
|
||||||
|
onStateChange?: (state: CircuitState, previousState: CircuitState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FAILURE_THRESHOLD = 5;
|
||||||
|
const DEFAULT_SUCCESS_THRESHOLD = 3;
|
||||||
|
const DEFAULT_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
export class CircuitBreakerError extends Error {
|
||||||
|
public readonly state: CircuitState;
|
||||||
|
|
||||||
|
constructor(message: string, state: CircuitState) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'CircuitBreakerError';
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CircuitBreaker {
|
||||||
|
private state: CircuitState = 'CLOSED';
|
||||||
|
private failureCount = 0;
|
||||||
|
private successCount = 0;
|
||||||
|
private lastFailureTime: Date | null = null;
|
||||||
|
private lastSuccessTime: Date | null = null;
|
||||||
|
private stateChangedAt: Date | null = null;
|
||||||
|
private totalExecutions = 0;
|
||||||
|
private totalFailures = 0;
|
||||||
|
private totalSuccesses = 0;
|
||||||
|
|
||||||
|
private readonly failureThreshold: number;
|
||||||
|
private readonly successThreshold: number;
|
||||||
|
private readonly timeout: number;
|
||||||
|
private readonly onStateChange?: (state: CircuitState, previousState: CircuitState) => void;
|
||||||
|
|
||||||
|
constructor(options?: CircuitBreakerOptions) {
|
||||||
|
this.failureThreshold = options?.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
|
||||||
|
this.successThreshold = options?.successThreshold ?? DEFAULT_SUCCESS_THRESHOLD;
|
||||||
|
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
this.onStateChange = options?.onStateChange;
|
||||||
|
this.stateChangedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getState(): CircuitState {
|
||||||
|
if (this.state === 'OPEN') {
|
||||||
|
const elapsed = Date.now() - this.lastFailureTime!.getTime();
|
||||||
|
if (elapsed >= this.timeout) {
|
||||||
|
this.transitionTo('HALF_OPEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async execute<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
fallback?: () => T | Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
this.totalExecutions++;
|
||||||
|
const currentState = this.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result: T;
|
||||||
|
|
||||||
|
if (currentState === 'OPEN') {
|
||||||
|
throw new CircuitBreakerError(
|
||||||
|
`Circuit is OPEN. Failures: ${this.failureCount}/${this.failureThreshold}`,
|
||||||
|
this.state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await fn();
|
||||||
|
this.recordSuccess();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.recordFailure();
|
||||||
|
|
||||||
|
if (fallback) {
|
||||||
|
try {
|
||||||
|
return fallback();
|
||||||
|
} catch (fallbackError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMetrics(): CircuitBreakerMetrics {
|
||||||
|
return {
|
||||||
|
state: this.getState(),
|
||||||
|
failureCount: this.failureCount,
|
||||||
|
successCount: this.successCount,
|
||||||
|
lastFailureTime: this.lastFailureTime,
|
||||||
|
lastSuccessTime: this.lastSuccessTime,
|
||||||
|
stateChangedAt: this.stateChangedAt,
|
||||||
|
totalExecutions: this.totalExecutions,
|
||||||
|
totalFailures: this.totalFailures,
|
||||||
|
totalSuccesses: this.totalSuccesses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
const previousState = this.state;
|
||||||
|
this.state = 'CLOSED';
|
||||||
|
this.failureCount = 0;
|
||||||
|
this.successCount = 0;
|
||||||
|
this.lastFailureTime = null;
|
||||||
|
this.lastSuccessTime = null;
|
||||||
|
this.stateChangedAt = new Date();
|
||||||
|
if (previousState !== 'CLOSED') {
|
||||||
|
this.emitStateChange('CLOSED', previousState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordSuccess(): void {
|
||||||
|
this.lastSuccessTime = new Date();
|
||||||
|
this.totalSuccesses++;
|
||||||
|
|
||||||
|
if (this.state === 'HALF_OPEN') {
|
||||||
|
this.successCount++;
|
||||||
|
if (this.successCount >= this.successThreshold) {
|
||||||
|
this.transitionTo('CLOSED');
|
||||||
|
this.failureCount = 0;
|
||||||
|
this.successCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordFailure(): void {
|
||||||
|
this.lastFailureTime = new Date();
|
||||||
|
this.totalFailures++;
|
||||||
|
this.failureCount++;
|
||||||
|
|
||||||
|
if (this.state === 'HALF_OPEN') {
|
||||||
|
this.transitionTo('OPEN');
|
||||||
|
} else if (this.state === 'CLOSED' && this.failureCount >= this.failureThreshold) {
|
||||||
|
this.transitionTo('OPEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private transitionTo(newState: CircuitState): void {
|
||||||
|
const previousState = this.state;
|
||||||
|
this.state = newState;
|
||||||
|
this.stateChangedAt = new Date();
|
||||||
|
if (newState === 'CLOSED') {
|
||||||
|
this.successCount = 0;
|
||||||
|
}
|
||||||
|
this.emitStateChange(newState, previousState);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitStateChange(newState: CircuitState, previousState: CircuitState): void {
|
||||||
|
if (this.onStateChange && newState !== previousState) {
|
||||||
|
this.onStateChange(newState, previousState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
services/spamshield/src/circuit-breaker/index.ts
Normal file
2
services/spamshield/src/circuit-breaker/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { CircuitBreaker, CircuitBreakerError } from './circuit-breaker';
|
||||||
|
export type { CircuitState, CircuitBreakerMetrics, CircuitBreakerOptions } from './circuit-breaker';
|
||||||
5
services/spamshield/src/index.ts
Normal file
5
services/spamshield/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { SpamShieldService } from './services/spamshield.service';
|
||||||
|
export type { ReputationResult, CircuitMetrics } from './services/spamshield.service';
|
||||||
|
export { spamRateLimits, spamFeatureFlags, spamConfig } from './config/spamshield.config';
|
||||||
|
export { CircuitBreaker, CircuitBreakerError } from './circuit-breaker';
|
||||||
|
export type { CircuitState, CircuitBreakerMetrics, CircuitBreakerOptions } from './circuit-breaker';
|
||||||
285
services/spamshield/src/services/spamshield.service.ts
Normal file
285
services/spamshield/src/services/spamshield.service.ts
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { PrismaClient, SpamFeedback, SpamRule, SpamAuditLog } from '@prisma/client';
|
||||||
|
import { FieldEncryptionService } from '@shieldai/db';
|
||||||
|
import { spamConfig, spamFeatureFlags } from '../config/spamshield.config';
|
||||||
|
import { CircuitBreaker, CircuitBreakerError, CircuitState, CircuitBreakerMetrics } from '../circuit-breaker';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient() as PrismaClient & {
|
||||||
|
spamFeedback: {
|
||||||
|
create: (data: { data: SpamFeedback }) => Promise<SpamFeedback>;
|
||||||
|
};
|
||||||
|
spamRule: {
|
||||||
|
findMany: (args: { where: { isActive: boolean } }) => Promise<SpamRule[]>;
|
||||||
|
};
|
||||||
|
spamAuditLog: {
|
||||||
|
create: (data: { data: SpamAuditLog }) => Promise<SpamAuditLog>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface InitializationLock {
|
||||||
|
promise: Promise<void>;
|
||||||
|
resolved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReputationResult {
|
||||||
|
score: number;
|
||||||
|
isSpam: boolean;
|
||||||
|
source: 'hiya' | 'truecaller' | 'combined' | 'fallback';
|
||||||
|
hiyaScore?: number;
|
||||||
|
truecallerScore?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitMetrics {
|
||||||
|
hiya: CircuitBreakerMetrics;
|
||||||
|
truecaller: CircuitBreakerMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SpamShieldService {
|
||||||
|
private static instance: SpamShieldService;
|
||||||
|
private initLock: InitializationLock | null = null;
|
||||||
|
private hiyaBreaker: CircuitBreaker = new CircuitBreaker({
|
||||||
|
failureThreshold: spamConfig.circuitBreakerThreshold,
|
||||||
|
timeout: spamConfig.circuitBreakerTimeout,
|
||||||
|
});
|
||||||
|
private truecallerBreaker: CircuitBreaker = new CircuitBreaker({
|
||||||
|
failureThreshold: spamConfig.circuitBreakerThreshold,
|
||||||
|
timeout: spamConfig.circuitBreakerTimeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): SpamShieldService {
|
||||||
|
if (!SpamShieldService.instance) {
|
||||||
|
SpamShieldService.instance = new SpamShieldService();
|
||||||
|
}
|
||||||
|
return SpamShieldService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.initLock?.resolved) return;
|
||||||
|
|
||||||
|
if (!this.initLock) {
|
||||||
|
this.initLock = {
|
||||||
|
promise: this._initialize(),
|
||||||
|
resolved: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.initLock.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _initialize(): Promise<void> {
|
||||||
|
this.hiyaBreaker = new CircuitBreaker({
|
||||||
|
failureThreshold: spamConfig.circuitBreakerThreshold,
|
||||||
|
timeout: spamConfig.circuitBreakerTimeout,
|
||||||
|
onStateChange: (state: CircuitState, previous: CircuitState) => {
|
||||||
|
console.log(`[SpamShield] Hiya circuit: ${previous} -> ${state}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.truecallerBreaker = new CircuitBreaker({
|
||||||
|
failureThreshold: spamConfig.circuitBreakerThreshold,
|
||||||
|
timeout: spamConfig.circuitBreakerTimeout,
|
||||||
|
onStateChange: (state: CircuitState, previous: CircuitState) => {
|
||||||
|
console.log(`[SpamShield] Truecaller circuit: ${previous} -> ${state}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.initLock!.resolved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkReputation(phoneNumber: string): Promise<ReputationResult> {
|
||||||
|
const validated = this.validatePhoneNumber(phoneNumber);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
this.fetchHiyaReputation(validated),
|
||||||
|
this.fetchTruecallerReputation(validated),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hiyaResult = results[0];
|
||||||
|
const truecallerResult = results[1];
|
||||||
|
|
||||||
|
const hiyaScore = hiyaResult.status === 'fulfilled' ? hiyaResult.value : undefined;
|
||||||
|
const truecallerScore = truecallerResult.status === 'fulfilled' ? truecallerResult.value : undefined;
|
||||||
|
|
||||||
|
if (hiyaScore !== undefined && truecallerScore !== undefined) {
|
||||||
|
const combinedScore = (hiyaScore + truecallerScore) / 2;
|
||||||
|
const isSpam = combinedScore > spamConfig.defaultConfidenceThreshold;
|
||||||
|
return {
|
||||||
|
score: combinedScore,
|
||||||
|
isSpam,
|
||||||
|
source: 'combined',
|
||||||
|
hiyaScore,
|
||||||
|
truecallerScore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hiyaScore !== undefined) {
|
||||||
|
return {
|
||||||
|
score: hiyaScore,
|
||||||
|
isSpam: hiyaScore > spamConfig.defaultConfidenceThreshold,
|
||||||
|
source: 'hiya',
|
||||||
|
hiyaScore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (truecallerScore !== undefined) {
|
||||||
|
return {
|
||||||
|
score: truecallerScore,
|
||||||
|
isSpam: truecallerScore > spamConfig.defaultConfidenceThreshold,
|
||||||
|
source: 'truecaller',
|
||||||
|
truecallerScore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
score: 0,
|
||||||
|
isSpam: false,
|
||||||
|
source: 'fallback',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async analyzeCall(phoneNumber: string, callTimestamp: Date): Promise<{
|
||||||
|
decision: 'BLOCK' | 'FLAG' | 'ALLOW';
|
||||||
|
confidence: number;
|
||||||
|
ruleMatches: string[];
|
||||||
|
}> {
|
||||||
|
const validated = this.validatePhoneNumber(phoneNumber);
|
||||||
|
const rules = await this.getActiveRules();
|
||||||
|
|
||||||
|
const ruleMatches: string[] = [];
|
||||||
|
let confidence = 0;
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const pattern = new RegExp(rule.pattern);
|
||||||
|
if (pattern.test(validated)) {
|
||||||
|
ruleMatches.push(rule.id);
|
||||||
|
confidence += 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confidence = Math.min(confidence, 1.0);
|
||||||
|
const decision = confidence > 0.8 ? 'BLOCK' : confidence > 0.5 ? 'FLAG' : 'ALLOW';
|
||||||
|
|
||||||
|
await prisma.spamAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: 'system',
|
||||||
|
phoneNumber: validated,
|
||||||
|
decision: decision as any,
|
||||||
|
reason: `Rule-based analysis`,
|
||||||
|
ruleId: ruleMatches[0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { decision, confidence, ruleMatches };
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordFeedback(
|
||||||
|
userId: string,
|
||||||
|
phoneNumber: string,
|
||||||
|
isSpam: boolean,
|
||||||
|
label?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const validated = this.validatePhoneNumber(phoneNumber);
|
||||||
|
const encrypted = FieldEncryptionService.encrypt(validated);
|
||||||
|
const hash = FieldEncryptionService.hashPhoneNumber(validated);
|
||||||
|
|
||||||
|
await prisma.spamFeedback.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
phoneNumber: encrypted,
|
||||||
|
phoneNumberHash: hash,
|
||||||
|
isSpam,
|
||||||
|
label,
|
||||||
|
metadata: JSON.stringify({ source: 'user_feedback' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCircuitMetrics(): CircuitMetrics {
|
||||||
|
return {
|
||||||
|
hiya: this.hiyaBreaker.getMetrics(),
|
||||||
|
truecaller: this.truecallerBreaker.getMetrics(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resetCircuits(): void {
|
||||||
|
this.hiyaBreaker.reset();
|
||||||
|
this.truecallerBreaker.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchHiyaReputation(phoneNumber: string): Promise<number> {
|
||||||
|
if (!spamFeatureFlags.enableHiyaIntegration) {
|
||||||
|
throw new Error('Hiya integration disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.hiyaBreaker.execute(
|
||||||
|
async () => {
|
||||||
|
const url = `https://api.hiya.com/reputation/${encodeURIComponent(phoneNumber)}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${process.env.HIYA_API_KEY}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Hiya API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { spamScore?: number; reputation?: { score?: number } };
|
||||||
|
const score = data.spamScore ?? data.reputation?.score ?? 0;
|
||||||
|
return score;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
console.log('[SpamShield] Hiya fallback: circuit OPEN, returning neutral score');
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchTruecallerReputation(phoneNumber: string): Promise<number> {
|
||||||
|
if (!spamFeatureFlags.enableTruecallerIntegration) {
|
||||||
|
throw new Error('Truecaller integration disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.truecallerBreaker.execute(
|
||||||
|
async () => {
|
||||||
|
const url = `https://redirect.truecaller.com/api/v2-ac/absolute/${encodeURIComponent(phoneNumber)}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'contentType': 'lookupNumber',
|
||||||
|
'Authorization': `Basic ${Buffer.from(process.env.TRUECALLER_API_KEY || '').toString('base64')}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Truecaller API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { spamProbability?: number; spam_type?: number };
|
||||||
|
const probability = data.spamProbability ?? (data.spam_type ? 0.8 : 0);
|
||||||
|
return probability;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
console.log('[SpamShield] Truecaller fallback: circuit OPEN, returning neutral score');
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validatePhoneNumber(phoneNumber: string): string {
|
||||||
|
if (phoneNumber.length < spamConfig.minPhoneNumberLength ||
|
||||||
|
phoneNumber.length > spamConfig.maxPhoneNumberLength) {
|
||||||
|
throw new Error(`Invalid phone number format: ${phoneNumber}`);
|
||||||
|
}
|
||||||
|
return phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getActiveRules(): Promise<Array<{ id: string; pattern: string }>> {
|
||||||
|
return prisma.spamRule.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true, pattern: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
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