export class NetworkError extends Error { constructor( message: string, public readonly cause?: unknown ) { super(message); this.name = "NetworkError"; } } export class TimeoutError extends Error { constructor( message: string, public readonly timeoutMs: number ) { super(message); this.name = "TimeoutError"; } } export class APIError extends Error { constructor( message: string, public readonly status: number, public readonly statusText: string ) { super(message); this.name = "APIError"; } } interface FetchWithTimeoutOptions extends RequestInit { timeout?: number; } export async function fetchWithTimeout( url: string, options: FetchWithTimeoutOptions = {} ): Promise { const { timeout = 10000, ...fetchOptions } = options; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { ...fetchOptions, signal: controller.signal }); clearTimeout(timeoutId); return response; } catch (error: unknown) { clearTimeout(timeoutId); if (error instanceof Error) { if (error.name === "AbortError") { throw new TimeoutError( `Request to ${url} timed out after ${timeout}ms`, timeout ); } if ( error.message.includes("fetch failed") || error.message.includes("ECONNREFUSED") || error.message.includes("ENOTFOUND") || error.message.includes("ETIMEDOUT") || error.message.includes("UND_ERR_CONNECT_TIMEOUT") || error.name === "FailedToOpenSocket" || error.message.includes("Was there a typo") ) { throw new NetworkError( `Failed to connect to ${url}: ${error.message}`, error ); } } throw error; } } export async function checkResponse(response: Response): Promise { if (!response.ok) { throw new APIError( `API request failed: ${response.statusText}`, response.status, response.statusText ); } return response; } export async function safeJsonParse(response: Response): Promise { try { return await response.json(); } catch (error) { console.error("Failed to parse JSON response:", error); return null; } } export async function fetchWithRetry( fn: () => Promise, options: { maxRetries?: number; retryDelay?: number; retryableErrors?: (error: unknown) => boolean; } = {} ): Promise { const { maxRetries = 2, retryDelay = 1000, retryableErrors = (error) => error instanceof TimeoutError || error instanceof NetworkError } = options; let lastError: unknown; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error; if (attempt === maxRetries || !retryableErrors(error)) { throw error; } const delay = retryDelay * Math.pow(2, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); } } throw lastError; }