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';
|
||||
Reference in New Issue
Block a user