Files
stack/apps/api/src/federation/utils/retry.ts
Jason Woltje 0b90012947
Some checks failed
ci/woodpecker/pr/woodpecker Pipeline failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
2026-02-03 22:07:55 -06:00

147 lines
3.6 KiB
TypeScript

/**
* 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));
}