feat(#293): implement retry logic with exponential backoff
Add retry capability with exponential backoff for HTTP requests. - Implement withRetry utility with configurable retry logic - Exponential backoff: 1s, 2s, 4s, 8s (max) - Maximum 3 retries by default - Retry on network errors (ECONNREFUSED, ETIMEDOUT, etc.) - Retry on 5xx server errors and 429 rate limit - Do NOT retry on 4xx client errors - Integrate with connection service for HTTP requests Fixes #293 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
146
apps/api/src/federation/utils/retry.ts
Normal file
146
apps/api/src/federation/utils/retry.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Retry Utility
|
||||
*
|
||||
* Provides retry logic with exponential backoff for HTTP requests.
|
||||
*/
|
||||
|
||||
import { Logger } from "@nestjs/common";
|
||||
import type { AxiosError } from "axios";
|
||||
|
||||
const logger = new Logger("RetryUtil");
|
||||
|
||||
/**
|
||||
* Configuration for retry logic
|
||||
*/
|
||||
export interface RetryConfig {
|
||||
/** Maximum number of retry attempts (default: 3) */
|
||||
maxRetries?: number;
|
||||
/** Initial backoff delay in milliseconds (default: 1000) */
|
||||
initialDelay?: number;
|
||||
/** Maximum backoff delay in milliseconds (default: 8000) */
|
||||
maxDelay?: number;
|
||||
/** Backoff multiplier (default: 2 for exponential) */
|
||||
backoffMultiplier?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default retry configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<RetryConfig> = {
|
||||
maxRetries: 3,
|
||||
initialDelay: 1000, // 1 second
|
||||
maxDelay: 8000, // 8 seconds
|
||||
backoffMultiplier: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if error is retryable (network errors, timeouts, 5xx errors)
|
||||
* Do NOT retry on 4xx errors (client errors)
|
||||
*/
|
||||
export function isRetryableError(error: unknown): boolean {
|
||||
// Check if it's a plain object (for testing) or Error instance
|
||||
if (!error || (typeof error !== "object" && !(error instanceof Error))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const axiosError = error as AxiosError;
|
||||
|
||||
// Retry on network errors (no response received)
|
||||
if (!axiosError.response) {
|
||||
// Check for network error codes
|
||||
const networkErrorCodes = [
|
||||
"ECONNREFUSED",
|
||||
"ETIMEDOUT",
|
||||
"ENOTFOUND",
|
||||
"ENETUNREACH",
|
||||
"EAI_AGAIN",
|
||||
];
|
||||
|
||||
if (axiosError.code && networkErrorCodes.includes(axiosError.code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retry on timeout
|
||||
if (axiosError.message.includes("timeout")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Retry on 5xx server errors
|
||||
const status = axiosError.response.status;
|
||||
if (status >= 500 && status < 600) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retry on 429 (Too Many Requests) with backoff
|
||||
if (status === 429) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Do NOT retry on 4xx client errors
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with retry logic and exponential backoff
|
||||
*/
|
||||
export async function withRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
config: RetryConfig = {}
|
||||
): Promise<T> {
|
||||
const finalConfig: Required<RetryConfig> = {
|
||||
...DEFAULT_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
let lastError: Error | undefined;
|
||||
let delay = finalConfig.initialDelay;
|
||||
|
||||
for (let attempt = 0; attempt <= finalConfig.maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
// If this is the last attempt, don't retry
|
||||
if (attempt === finalConfig.maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if error is retryable
|
||||
if (!isRetryableError(error)) {
|
||||
logger.warn(`Non-retryable error, aborting retry: ${lastError.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Log retry attempt
|
||||
const errorMessage = lastError instanceof Error ? lastError.message : "Unknown error";
|
||||
logger.warn(
|
||||
`Retry attempt ${String(attempt + 1)}/${String(finalConfig.maxRetries)} after error: ${errorMessage}. Retrying in ${String(delay)}ms...`
|
||||
);
|
||||
|
||||
// Wait with exponential backoff
|
||||
await sleep(delay);
|
||||
|
||||
// Calculate next delay with exponential backoff
|
||||
delay = Math.min(delay * finalConfig.backoffMultiplier, finalConfig.maxDelay);
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Should never reach here, but satisfy TypeScript
|
||||
throw new Error("Operation failed after retries");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user