Files
gridpilot.gg/apps/website/lib/api/base/RetryHandler.ts
2026-01-16 01:00:03 +01:00

275 lines
6.4 KiB
TypeScript

/**
* Retry logic and circuit breaker for API requests
*/
import { ApiError } 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;
}
}