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:
2026-04-29 19:07:54 -04:00
parent 509259bcf2
commit 3ad030a412
6 changed files with 949 additions and 0 deletions

View 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);
}
}
}

View File

@@ -0,0 +1,2 @@
export { CircuitBreaker, CircuitBreakerError } from './circuit-breaker';
export type { CircuitState, CircuitBreakerMetrics, CircuitBreakerOptions } from './circuit-breaker';

View 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';

View 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 },
});
}
}