/**
* Утилиты для обработки ошибок и retry логики
*/
import axios from "axios";
export interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
backoffMultiplier: number;
retryableStatusCodes: number[];
}
export interface CircuitBreakerConfig {
failureThreshold: number;
recoveryTimeoutMs: number;
monitoringWindowMs: number;
}
export interface ErrorContext {
operation: string;
url?: string;
method?: string;
attempt?: number;
timestamp: string;
userId?: string;
companyId?: string;
}
export class RetryHandler {
private static readonly DEFAULT_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelayMs: 500,
maxDelayMs: 10000,
backoffMultiplier: 2,
retryableStatusCodes: [500, 502, 503, 504, 429],
};
/**
* Выполняет операцию с retry логикой
*/
static async executeWithRetry<T>(
operation: () => Promise<T>,
config: Partial<RetryConfig> = {},
context: ErrorContext
): Promise<T> {
const finalConfig = { ...this.DEFAULT_CONFIG, ...config };
let lastError: Error | null = null;
let delayMs = finalConfig.baseDelayMs;
for (let attempt = 1; attempt <= finalConfig.maxRetries; attempt++) {
try {
console.log(`[RETRY] Attempt ${attempt}/${finalConfig.maxRetries} for ${context.operation}`);
return await operation();
} catch (error) {
lastError = error as Error;
const errorContext = { ...context, attempt };
// Логируем ошибку
this.logError(error, errorContext);
// Проверяем, нужно ли повторить
if (this.shouldRetry(error, attempt, finalConfig)) {
if (attempt < finalConfig.maxRetries) {
console.log(`[RETRY] Retrying in ${delayMs}ms... (attempt ${attempt + 1}/${finalConfig.maxRetries})`);
await this.delay(delayMs);
delayMs = Math.min(delayMs * finalConfig.backoffMultiplier, finalConfig.maxDelayMs);
continue;
}
}
// Если не нужно повторить или исчерпаны попытки
throw this.enhanceError(error, errorContext);
}
}
throw lastError || new Error(`Max retries (${finalConfig.maxRetries}) exceeded for ${context.operation}`);
}
/**
* Определяет, нужно ли повторить операцию
*/
private static shouldRetry(error: unknown, attempt: number, config: RetryConfig): boolean {
if (attempt >= config.maxRetries) {
return false;
}
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status && config.retryableStatusCodes.includes(status)) {
return true;
}
// Сетевые ошибки
if (!error.response && error.code) {
return ["ECONNRESET", "ENOTFOUND", "ECONNREFUSED", "ETIMEDOUT"].includes(error.code);
}
}
return false;
}
/**
* Задержка с возможностью прерывания
*/
private static delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Логирует ошибку
*/
private static logError(error: unknown, context: ErrorContext): void {
const errorInfo: Record<string, unknown> = {
timestamp: context.timestamp,
operation: context.operation,
attempt: context.attempt,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
context: {
url: context.url,
method: context.method,
userId: context.userId,
companyId: context.companyId,
},
};
if (axios.isAxiosError(error)) {
errorInfo["httpStatus"] = error.response?.status;
errorInfo["httpData"] = error.response?.data;
}
console.error("[ERROR] Retry operation failed:", JSON.stringify(errorInfo, null, 2));
}
/**
* Улучшает ошибку дополнительной информацией
*/
private static enhanceError(error: unknown, context: ErrorContext): Error {
if (error instanceof Error) {
const enhancedMessage = `${error.message} (operation: ${context.operation}, attempt: ${context.attempt})`;
const enhancedError = new Error(enhancedMessage);
enhancedError.stack = error.stack;
return enhancedError;
}
return new Error(`Unknown error in ${context.operation} (attempt: ${context.attempt}): ${String(error)}`);
}
}
export class CircuitBreaker {
private failures: number = 0;
private lastFailureTime: number = 0;
private state: "CLOSED" | "OPEN" | "HALF_OPEN" = "CLOSED";
constructor(private config: CircuitBreakerConfig) {}
/**
* Выполняет операцию через circuit breaker
*/
async execute<T>(operation: () => Promise<T>, context: ErrorContext): Promise<T> {
if (this.state === "OPEN") {
if (Date.now() - this.lastFailureTime > this.config.recoveryTimeoutMs) {
this.state = "HALF_OPEN";
console.log(`[CIRCUIT_BREAKER] Moving to HALF_OPEN state for ${context.operation}`);
} else {
throw new Error(`Circuit breaker is OPEN for ${context.operation}. Service unavailable.`);
}
}
try {
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.failures = 0;
if (this.state === "HALF_OPEN") {
this.state = "CLOSED";
console.log("[CIRCUIT_BREAKER] Moving to CLOSED state - service recovered");
}
}
private onFailure(): void {
this.failures++;
this.lastFailureTime = Date.now();
if (this.failures >= this.config.failureThreshold) {
this.state = "OPEN";
console.log(`[CIRCUIT_BREAKER] Moving to OPEN state - ${this.failures} failures detected`);
}
}
getState(): string {
return this.state;
}
getFailures(): number {
return this.failures;
}
}
export class ErrorHandler {
private static circuitBreakers = new Map<string, CircuitBreaker>();
/**
* Получает или создаёт circuit breaker для операции
*/
private static getCircuitBreaker(operation: string): CircuitBreaker {
if (!this.circuitBreakers.has(operation)) {
this.circuitBreakers.set(
operation,
new CircuitBreaker({
failureThreshold: 5,
recoveryTimeoutMs: 30000, // 30 секунд
monitoringWindowMs: 60000, // 1 минута
})
);
}
return this.circuitBreakers.get(operation)!;
}
/**
* Выполняет операцию с retry и circuit breaker
*/
static async executeWithResilience<T>(
operation: () => Promise<T>,
context: ErrorContext,
retryConfig?: Partial<RetryConfig>
): Promise<T> {
const circuitBreaker = this.getCircuitBreaker(context.operation);
return circuitBreaker.execute(async () => {
return RetryHandler.executeWithRetry(operation, retryConfig, context);
}, context);
}
/**
* Обрабатывает специфичные ошибки API
*/
static handleApiError(error: unknown, context: ErrorContext): Error {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const data = error.response?.data;
switch (status) {
case 400:
return new Error(`Bad Request: ${data?.message || "Invalid request parameters"}`);
case 401:
return new Error("Unauthorized: Invalid or missing API key");
case 403:
return new Error("Forbidden: Insufficient permissions");
case 404:
return new Error("Not Found: Resource does not exist");
case 409:
return new Error(`Conflict: ${data?.message || "Resource already exists"}`);
case 422:
return new Error(`Validation Error: ${data?.message || "Invalid data format"}`);
case 429:
return new Error("Rate Limited: Too many requests, please try again later");
case 500:
return new Error("Internal Server Error: Please try again later");
case 502:
case 503:
case 504:
return new Error("Service Unavailable: Please try again later");
default:
return new Error(`HTTP ${status}: ${data?.message || error.message}`);
}
}
return error instanceof Error ? error : new Error(String(error));
}
/**
* Создаёт контекст ошибки
*/
static createErrorContext(
operation: string,
url?: string,
method?: string,
userId?: string,
companyId?: string
): ErrorContext {
return {
operation,
url,
method,
timestamp: new Date().toISOString(),
userId,
companyId,
};
}
}