|
1 | 1 | import { IFetchComponent } from '@well-known-components/http-server' |
| 2 | +import { IConfigComponent, ILoggerComponent } from '@well-known-components/interfaces' |
2 | 3 | import * as nodeFetch from 'node-fetch' |
3 | 4 |
|
4 | | -export async function createFetchComponent() { |
5 | | - const fetch: IFetchComponent = { |
| 5 | +// Error codes that indicate transient network failures |
| 6 | +const RETRYABLE_ERROR_CODES = new Set([ |
| 7 | + 'ENOTFOUND', // DNS resolution failure |
| 8 | + 'ETIMEDOUT', // Connection timeout |
| 9 | + 'ECONNRESET', // Connection reset |
| 10 | + 'ECONNREFUSED', // Connection refused |
| 11 | + 'EPIPE', // Broken pipe |
| 12 | + 'ENETUNREACH', // Network unreachable |
| 13 | + 'EHOSTUNREACH', // Host unreachable |
| 14 | + 'EAI_AGAIN' // DNS temporary failure |
| 15 | +]) |
| 16 | + |
| 17 | +// HTTP status codes that indicate transient server failures |
| 18 | +const RETRYABLE_HTTP_STATUS = new Set([ |
| 19 | + 408, // Request Timeout |
| 20 | + 429, // Too Many Requests |
| 21 | + 500, // Internal Server Error |
| 22 | + 502, // Bad Gateway |
| 23 | + 503, // Service Unavailable |
| 24 | + 504 // Gateway Timeout |
| 25 | +]) |
| 26 | + |
| 27 | +interface FetchConfig { |
| 28 | + maxRetries: number |
| 29 | + initialDelayMs: number |
| 30 | + maxDelayMs: number |
| 31 | + timeoutMs: number |
| 32 | + backoffMultiplier: number |
| 33 | +} |
| 34 | + |
| 35 | +function isRetryableError(error: unknown): boolean { |
| 36 | + if (error && typeof error === 'object' && 'code' in error) { |
| 37 | + return RETRYABLE_ERROR_CODES.has((error as { code: string }).code) |
| 38 | + } |
| 39 | + return false |
| 40 | +} |
| 41 | + |
| 42 | +function isRetryableStatus(status: number): boolean { |
| 43 | + return RETRYABLE_HTTP_STATUS.has(status) |
| 44 | +} |
| 45 | + |
| 46 | +function calculateDelay(attempt: number, config: FetchConfig): number { |
| 47 | + // Exponential backoff: initialDelay * (multiplier ^ attempt) |
| 48 | + const exponentialDelay = config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt) |
| 49 | + // Cap at max delay |
| 50 | + const cappedDelay = Math.min(exponentialDelay, config.maxDelayMs) |
| 51 | + // Add jitter (0-25% of delay) |
| 52 | + const jitter = cappedDelay * Math.random() * 0.25 |
| 53 | + return Math.floor(cappedDelay + jitter) |
| 54 | +} |
| 55 | + |
| 56 | +function sleep(ms: number): Promise<void> { |
| 57 | + return new Promise((resolve) => setTimeout(resolve, ms)) |
| 58 | +} |
| 59 | + |
| 60 | +export async function createFetchComponent(deps?: { |
| 61 | + config?: IConfigComponent |
| 62 | + logs?: ILoggerComponent |
| 63 | +}): Promise<IFetchComponent> { |
| 64 | + const config = deps?.config |
| 65 | + const logger = deps?.logs?.getLogger('fetch') |
| 66 | + |
| 67 | + // Load configuration with defaults |
| 68 | + const fetchConfig: FetchConfig = { |
| 69 | + maxRetries: parseInt((await config?.getString('FETCH_MAX_RETRIES')) ?? '3', 10), |
| 70 | + initialDelayMs: parseInt((await config?.getString('FETCH_INITIAL_DELAY_MS')) ?? '1000', 10), |
| 71 | + maxDelayMs: parseInt((await config?.getString('FETCH_MAX_DELAY_MS')) ?? '30000', 10), |
| 72 | + timeoutMs: parseInt((await config?.getString('FETCH_TIMEOUT_MS')) ?? '60000', 10), |
| 73 | + backoffMultiplier: parseFloat((await config?.getString('FETCH_BACKOFF_MULTIPLIER')) ?? '2') |
| 74 | + } |
| 75 | + |
| 76 | + const fetchComponent: IFetchComponent = { |
6 | 77 | async fetch(url: nodeFetch.RequestInfo, init?: nodeFetch.RequestInit): Promise<nodeFetch.Response> { |
7 | | - return nodeFetch.default(url, init) |
| 78 | + const urlString = typeof url === 'string' ? url : String(url) |
| 79 | + let lastError: Error | undefined |
| 80 | + |
| 81 | + for (let attempt = 0; attempt <= fetchConfig.maxRetries; attempt++) { |
| 82 | + // Create AbortController for timeout |
| 83 | + const controller = new AbortController() |
| 84 | + const timeoutId = setTimeout(() => controller.abort(), fetchConfig.timeoutMs) |
| 85 | + |
| 86 | + try { |
| 87 | + // Merge abort signal with any existing signal |
| 88 | + const mergedInit: nodeFetch.RequestInit = { |
| 89 | + ...init, |
| 90 | + signal: controller.signal as nodeFetch.RequestInit['signal'] |
| 91 | + } |
| 92 | + |
| 93 | + const response = await nodeFetch.default(url, mergedInit) |
| 94 | + clearTimeout(timeoutId) |
| 95 | + |
| 96 | + // Check for retryable HTTP status |
| 97 | + if (isRetryableStatus(response.status) && attempt < fetchConfig.maxRetries) { |
| 98 | + const delay = calculateDelay(attempt, fetchConfig) |
| 99 | + logger?.warn( |
| 100 | + `Retryable HTTP status ${response.status} for ${urlString}, attempt ${attempt + 1}/${fetchConfig.maxRetries + 1}, retrying in ${delay}ms` |
| 101 | + ) |
| 102 | + await sleep(delay) |
| 103 | + continue |
| 104 | + } |
| 105 | + |
| 106 | + return response |
| 107 | + } catch (error) { |
| 108 | + clearTimeout(timeoutId) |
| 109 | + lastError = error instanceof Error ? error : new Error(String(error)) |
| 110 | + |
| 111 | + // Check if error is retryable |
| 112 | + const isAbortError = lastError.name === 'AbortError' |
| 113 | + const isNetworkError = isRetryableError(error) |
| 114 | + |
| 115 | + if ((isAbortError || isNetworkError) && attempt < fetchConfig.maxRetries) { |
| 116 | + const errorCode = isAbortError ? 'TIMEOUT' : ((error as { code?: string }).code ?? 'UNKNOWN') |
| 117 | + const delay = calculateDelay(attempt, fetchConfig) |
| 118 | + logger?.warn( |
| 119 | + `Network error ${errorCode} for ${urlString}, attempt ${attempt + 1}/${fetchConfig.maxRetries + 1}, retrying in ${delay}ms` |
| 120 | + ) |
| 121 | + await sleep(delay) |
| 122 | + continue |
| 123 | + } |
| 124 | + |
| 125 | + // Not retryable or retries exhausted |
| 126 | + throw lastError |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + // Should not reach here, but throw last error if we do |
| 131 | + throw lastError ?? new Error(`Fetch failed for ${urlString} after ${fetchConfig.maxRetries} retries`) |
8 | 132 | } |
9 | 133 | } |
10 | 134 |
|
11 | | - return fetch |
| 135 | + return fetchComponent |
12 | 136 | } |
0 commit comments