275 lines
6.5 KiB
TypeScript
275 lines
6.5 KiB
TypeScript
/**
|
|
* Retry logic and circuit breaker for API requests
|
|
*/
|
|
|
|
import { ApiError, ApiErrorType } from './ApiError';
|
|
|
|
export interface RetryConfig {
|
|
maxRetries: number;
|
|
baseDelay: number; // milliseconds
|
|
maxDelay: number; // milliseconds
|
|
backoffMultiplier: number;
|
|
timeout: number; // milliseconds
|
|
}
|
|
|
|
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
|
maxRetries: 3,
|
|
baseDelay: 1000,
|
|
maxDelay: 10000,
|
|
backoffMultiplier: 2,
|
|
timeout: 30000,
|
|
};
|
|
|
|
export interface CircuitBreakerConfig {
|
|
failureThreshold: number;
|
|
successThreshold: number;
|
|
timeout: number; // milliseconds before trying again
|
|
}
|
|
|
|
export const DEFAULT_CIRCUIT_BREAKER_CONFIG: CircuitBreakerConfig = {
|
|
failureThreshold: 5,
|
|
successThreshold: 3,
|
|
timeout: 60000, // 1 minute
|
|
};
|
|
|
|
export class CircuitBreaker {
|
|
private failures = 0;
|
|
private successes = 0;
|
|
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
|
|
private lastFailureTime: number | null = null;
|
|
private readonly config: CircuitBreakerConfig;
|
|
|
|
constructor(config: CircuitBreakerConfig = DEFAULT_CIRCUIT_BREAKER_CONFIG) {
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* Check if request should proceed
|
|
*/
|
|
canExecute(): boolean {
|
|
if (this.state === 'CLOSED') {
|
|
return true;
|
|
}
|
|
|
|
if (this.state === 'OPEN') {
|
|
const now = Date.now();
|
|
if (this.lastFailureTime && now - this.lastFailureTime > this.config.timeout) {
|
|
this.state = 'HALF_OPEN';
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// HALF_OPEN - allow one request to test if service recovered
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Record a successful request
|
|
*/
|
|
recordSuccess(): void {
|
|
if (this.state === 'HALF_OPEN') {
|
|
this.successes++;
|
|
if (this.successes >= this.config.successThreshold) {
|
|
this.reset();
|
|
}
|
|
} else if (this.state === 'CLOSED') {
|
|
// Keep failures in check
|
|
this.failures = Math.max(0, this.failures - 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a failed request
|
|
*/
|
|
recordFailure(): void {
|
|
this.failures++;
|
|
this.lastFailureTime = Date.now();
|
|
|
|
if (this.state === 'HALF_OPEN') {
|
|
this.state = 'OPEN';
|
|
this.successes = 0;
|
|
} else if (this.state === 'CLOSED' && this.failures >= this.config.failureThreshold) {
|
|
this.state = 'OPEN';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current state
|
|
*/
|
|
getState(): string {
|
|
return this.state;
|
|
}
|
|
|
|
/**
|
|
* Get failure count
|
|
*/
|
|
getFailures(): number {
|
|
return this.failures;
|
|
}
|
|
|
|
/**
|
|
* Reset the circuit breaker
|
|
*/
|
|
reset(): void {
|
|
this.failures = 0;
|
|
this.successes = 0;
|
|
this.state = 'CLOSED';
|
|
this.lastFailureTime = null;
|
|
}
|
|
}
|
|
|
|
export class RetryHandler {
|
|
private config: RetryConfig;
|
|
private abortController: AbortController | null = null;
|
|
|
|
constructor(config: RetryConfig = DEFAULT_RETRY_CONFIG) {
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* Execute a function with retry logic
|
|
*/
|
|
async execute<T>(
|
|
fn: (signal: AbortSignal) => Promise<T>,
|
|
isRetryable?: (error: ApiError) => boolean
|
|
): Promise<T> {
|
|
this.abortController = new AbortController();
|
|
const signal = this.abortController.signal;
|
|
|
|
let lastError: Error | null = null;
|
|
|
|
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
|
try {
|
|
// Check if already aborted
|
|
if (signal.aborted) {
|
|
throw new Error('Request aborted');
|
|
}
|
|
|
|
const result = await fn(signal);
|
|
return result;
|
|
} catch (error) {
|
|
lastError = error as Error;
|
|
|
|
// Check if we should abort
|
|
if (signal.aborted) {
|
|
throw error;
|
|
}
|
|
|
|
// Check if this is the last attempt
|
|
if (attempt === this.config.maxRetries) {
|
|
break;
|
|
}
|
|
|
|
// Check if error is retryable
|
|
if (error instanceof ApiError) {
|
|
if (!error.isRetryable()) {
|
|
throw error;
|
|
}
|
|
if (isRetryable && !isRetryable(error)) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Calculate delay with exponential backoff
|
|
const delay = this.calculateDelay(attempt);
|
|
|
|
// Wait before retrying
|
|
await this.sleep(delay, signal);
|
|
}
|
|
}
|
|
|
|
// All retries exhausted
|
|
throw lastError;
|
|
}
|
|
|
|
/**
|
|
* Cancel the current request
|
|
*/
|
|
abort(): void {
|
|
if (this.abortController) {
|
|
this.abortController.abort();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate delay for retry attempt
|
|
*/
|
|
private calculateDelay(attempt: number): number {
|
|
const delay = Math.min(
|
|
this.config.baseDelay * Math.pow(this.config.backoffMultiplier, attempt),
|
|
this.config.maxDelay
|
|
);
|
|
// Add jitter to prevent thundering herd
|
|
const jitter = Math.random() * 0.3 * delay;
|
|
return delay + jitter;
|
|
}
|
|
|
|
/**
|
|
* Sleep with abort support
|
|
*/
|
|
private sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
signal.removeEventListener('abort', abortHandler);
|
|
resolve();
|
|
}, ms);
|
|
|
|
const abortHandler = () => {
|
|
clearTimeout(timeout);
|
|
signal.removeEventListener('abort', abortHandler);
|
|
reject(new Error('Request aborted during retry delay'));
|
|
};
|
|
|
|
signal.addEventListener('abort', abortHandler, { once: true });
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Global circuit breaker registry for different API endpoints
|
|
*/
|
|
export class CircuitBreakerRegistry {
|
|
private static instance: CircuitBreakerRegistry;
|
|
private breakers: Map<string, CircuitBreaker> = new Map();
|
|
|
|
private constructor() {}
|
|
|
|
static getInstance(): CircuitBreakerRegistry {
|
|
if (!CircuitBreakerRegistry.instance) {
|
|
CircuitBreakerRegistry.instance = new CircuitBreakerRegistry();
|
|
}
|
|
return CircuitBreakerRegistry.instance;
|
|
}
|
|
|
|
/**
|
|
* Get or create circuit breaker for a specific endpoint
|
|
*/
|
|
getBreaker(endpoint: string, config?: CircuitBreakerConfig): CircuitBreaker {
|
|
if (!this.breakers.has(endpoint)) {
|
|
this.breakers.set(endpoint, new CircuitBreaker(config));
|
|
}
|
|
return this.breakers.get(endpoint)!;
|
|
}
|
|
|
|
/**
|
|
* Reset all circuit breakers
|
|
*/
|
|
resetAll(): void {
|
|
this.breakers.forEach(breaker => breaker.reset());
|
|
}
|
|
|
|
/**
|
|
* Get status of all circuit breakers
|
|
*/
|
|
getStatus(): Record<string, { state: string; failures: number }> {
|
|
const status: Record<string, { state: string; failures: number }> = {};
|
|
this.breakers.forEach((breaker, endpoint) => {
|
|
status[endpoint] = {
|
|
state: breaker.getState(),
|
|
failures: breaker.getFailures(),
|
|
};
|
|
});
|
|
return status;
|
|
}
|
|
} |