diff options
Diffstat (limited to 'packages/server/src/utils/retry.ts')
| -rw-r--r-- | packages/server/src/utils/retry.ts | 227 |
1 files changed, 227 insertions, 0 deletions
diff --git a/packages/server/src/utils/retry.ts b/packages/server/src/utils/retry.ts new file mode 100644 index 00000000..1e7d5bcb --- /dev/null +++ b/packages/server/src/utils/retry.ts @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface RetryOptions { + maxAttempts: number; + initialDelayMs: number; + maxDelayMs: number; + shouldRetry: (error: Error) => boolean; +} + +const DEFAULT_RETRY_OPTIONS: RetryOptions = { + maxAttempts: 5, + initialDelayMs: 5000, + maxDelayMs: 30000, // 30 seconds + shouldRetry: defaultShouldRetry, +}; + +/** + * Default predicate function to determine if a retry should be attempted. + * Retries on 429 (Too Many Requests) and 5xx server errors. + * @param error The error object. + * @returns True if the error is a transient error, false otherwise. + */ +function defaultShouldRetry(error: Error | unknown): boolean { + // Check for common transient error status codes either in message or a status property + if (error && typeof (error as { status?: number }).status === 'number') { + const status = (error as { status: number }).status; + if (status === 429 || (status >= 500 && status < 600)) { + return true; + } + } + if (error instanceof Error && error.message) { + if (error.message.includes('429')) return true; + if (error.message.match(/5\d{2}/)) return true; + } + return false; +} + +/** + * Delays execution for a specified number of milliseconds. + * @param ms The number of milliseconds to delay. + * @returns A promise that resolves after the delay. + */ +function delay(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Retries a function with exponential backoff and jitter. + * @param fn The asynchronous function to retry. + * @param options Optional retry configuration. + * @returns A promise that resolves with the result of the function if successful. + * @throws The last error encountered if all attempts fail. + */ +export async function retryWithBackoff<T>( + fn: () => Promise<T>, + options?: Partial<RetryOptions>, +): Promise<T> { + const { maxAttempts, initialDelayMs, maxDelayMs, shouldRetry } = { + ...DEFAULT_RETRY_OPTIONS, + ...options, + }; + + let attempt = 0; + let currentDelay = initialDelayMs; + + while (attempt < maxAttempts) { + attempt++; + try { + return await fn(); + } catch (error) { + if (attempt >= maxAttempts || !shouldRetry(error as Error)) { + throw error; + } + + const { delayDurationMs, errorStatus } = getDelayDurationAndStatus(error); + + if (delayDurationMs > 0) { + // Respect Retry-After header if present and parsed + console.warn( + `Attempt ${attempt} failed with status ${errorStatus ?? 'unknown'}. Retrying after explicit delay of ${delayDurationMs}ms...`, + error, + ); + await delay(delayDurationMs); + // Reset currentDelay for next potential non-429 error, or if Retry-After is not present next time + currentDelay = initialDelayMs; + } else { + // Fallback to exponential backoff with jitter + logRetryAttempt(attempt, error, errorStatus); + // Add jitter: +/- 30% of currentDelay + const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1); + const delayWithJitter = Math.max(0, currentDelay + jitter); + await delay(delayWithJitter); + currentDelay = Math.min(maxDelayMs, currentDelay * 2); + } + } + } + // This line should theoretically be unreachable due to the throw in the catch block. + // Added for type safety and to satisfy the compiler that a promise is always returned. + throw new Error('Retry attempts exhausted'); +} + +/** + * Extracts the HTTP status code from an error object. + * @param error The error object. + * @returns The HTTP status code, or undefined if not found. + */ +function getErrorStatus(error: unknown): number | undefined { + if (typeof error === 'object' && error !== null) { + if ('status' in error && typeof error.status === 'number') { + return error.status; + } + // Check for error.response.status (common in axios errors) + if ( + 'response' in error && + typeof (error as { response?: unknown }).response === 'object' && + (error as { response?: unknown }).response !== null + ) { + const response = ( + error as { response: { status?: unknown; headers?: unknown } } + ).response; + if ('status' in response && typeof response.status === 'number') { + return response.status; + } + } + } + return undefined; +} + +/** + * Extracts the Retry-After delay from an error object's headers. + * @param error The error object. + * @returns The delay in milliseconds, or 0 if not found or invalid. + */ +function getRetryAfterDelayMs(error: unknown): number { + if (typeof error === 'object' && error !== null) { + // Check for error.response.headers (common in axios errors) + if ( + 'response' in error && + typeof (error as { response?: unknown }).response === 'object' && + (error as { response?: unknown }).response !== null + ) { + const response = (error as { response: { headers?: unknown } }).response; + if ( + 'headers' in response && + typeof response.headers === 'object' && + response.headers !== null + ) { + const headers = response.headers as { 'retry-after'?: unknown }; + const retryAfterHeader = headers['retry-after']; + if (typeof retryAfterHeader === 'string') { + const retryAfterSeconds = parseInt(retryAfterHeader, 10); + if (!isNaN(retryAfterSeconds)) { + return retryAfterSeconds * 1000; + } + // It might be an HTTP date + const retryAfterDate = new Date(retryAfterHeader); + if (!isNaN(retryAfterDate.getTime())) { + return Math.max(0, retryAfterDate.getTime() - Date.now()); + } + } + } + } + } + return 0; +} + +/** + * Determines the delay duration based on the error, prioritizing Retry-After header. + * @param error The error object. + * @returns An object containing the delay duration in milliseconds and the error status. + */ +function getDelayDurationAndStatus(error: unknown): { + delayDurationMs: number; + errorStatus: number | undefined; +} { + const errorStatus = getErrorStatus(error); + let delayDurationMs = 0; + + if (errorStatus === 429) { + delayDurationMs = getRetryAfterDelayMs(error); + } + return { delayDurationMs, errorStatus }; +} + +/** + * Logs a message for a retry attempt when using exponential backoff. + * @param attempt The current attempt number. + * @param error The error that caused the retry. + * @param errorStatus The HTTP status code of the error, if available. + */ +function logRetryAttempt( + attempt: number, + error: unknown, + errorStatus?: number, +): void { + let message = `Attempt ${attempt} failed. Retrying with backoff...`; + if (errorStatus) { + message = `Attempt ${attempt} failed with status ${errorStatus}. Retrying with backoff...`; + } + + if (errorStatus === 429) { + console.warn(message, error); + } else if (errorStatus && errorStatus >= 500 && errorStatus < 600) { + console.error(message, error); + } else if (error instanceof Error) { + // Fallback for errors that might not have a status but have a message + if (error.message.includes('429')) { + console.warn( + `Attempt ${attempt} failed with 429 error (no Retry-After header). Retrying with backoff...`, + error, + ); + } else if (error.message.match(/5\d{2}/)) { + console.error( + `Attempt ${attempt} failed with 5xx error. Retrying with backoff...`, + error, + ); + } else { + console.warn(message, error); // Default to warn for other errors + } + } else { + console.warn(message, error); // Default to warn if error type is unknown + } +} |
