dev setup
This commit is contained in:
275
apps/website/lib/api/base/RetryHandler.ts
Normal file
275
apps/website/lib/api/base/RetryHandler.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user