/** * Request Helper Module * * Provides request timeout, automatic retry with exponential backoff, * and request cancellation support for API clients. * * @module lib/request-helper */ import { createLogger } from './logger'; const log = createLogger('RequestHelper'); // === Configuration Types === export interface RequestConfig { /** Timeout in milliseconds, default 30000 */ timeout?: number; /** Number of retry attempts, default 3 */ retries?: number; /** Base retry delay in milliseconds, default 1000 (exponential backoff applied) */ retryDelay?: number; /** HTTP status codes that trigger retry, default [408, 429, 500, 502, 503, 504] */ retryOn?: number[]; /** Maximum retry delay cap in milliseconds, default 30000 */ maxRetryDelay?: number; } export const DEFAULT_REQUEST_CONFIG: Required = { timeout: 30000, retries: 3, retryDelay: 1000, retryOn: [408, 429, 500, 502, 503, 504], maxRetryDelay: 30000, }; // === Error Types === export class RequestError extends Error { constructor( message: string, public readonly status: number, public readonly statusText: string, public readonly responseBody?: string ) { super(message); this.name = 'RequestError'; } /** Check if error is retryable based on status code */ isRetryable(retryCodes: number[] = DEFAULT_REQUEST_CONFIG.retryOn): boolean { return retryCodes.includes(this.status); } /** Check if error is a timeout */ isTimeout(): boolean { return this.status === 408 || this.message.includes('timeout'); } /** Check if error is an authentication error (should NOT retry) */ isAuthError(): boolean { return this.status === 401 || this.status === 403; } } export class RequestCancelledError extends Error { constructor(message: string = 'Request cancelled') { super(message); this.name = 'RequestCancelledError'; } } // === Helper Functions === function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Calculate exponential backoff delay with jitter * @param baseDelay Base delay in ms * @param attempt Current attempt number (0-indexed) * @param maxDelay Maximum delay cap * @returns Delay in milliseconds */ function calculateBackoff( baseDelay: number, attempt: number, maxDelay: number = DEFAULT_REQUEST_CONFIG.maxRetryDelay ): number { // Exponential backoff: baseDelay * 2^attempt const exponentialDelay = baseDelay * Math.pow(2, attempt); // Cap at maxDelay const cappedDelay = Math.min(exponentialDelay, maxDelay); // Add jitter (0-25% of delay) to prevent thundering herd const jitter = cappedDelay * 0.25 * Math.random(); return Math.floor(cappedDelay + jitter); } // === Request with Retry === export interface RequestWithRetryOptions extends RequestInit { /** Request configuration for timeout and retry */ config?: RequestConfig; } /** * Execute a fetch request with timeout and automatic retry support. * * Features: * - Configurable timeout with AbortController * - Automatic retry with exponential backoff * - Configurable retry status codes * - Jitter to prevent thundering herd * * @param url Request URL * @param options Fetch options + request config * @param config Request configuration (timeout, retries, etc.) * @returns Promise * @throws RequestError on failure after all retries exhausted * @throws RequestCancelledError if request was cancelled */ export async function requestWithRetry( url: string, options: RequestInit = {}, config: RequestConfig = {} ): Promise { const { timeout = DEFAULT_REQUEST_CONFIG.timeout, retries = DEFAULT_REQUEST_CONFIG.retries, retryDelay = DEFAULT_REQUEST_CONFIG.retryDelay, retryOn = DEFAULT_REQUEST_CONFIG.retryOn, } = config; let lastError: RequestError | null = null; let responseBody = ''; for (let attempt = 0; attempt <= retries; attempt++) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { ...options, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { // Try to read response body for error details try { responseBody = await response.text(); } catch (e) { log.debug('Failed to read response body', { error: e }); responseBody = ''; } const error = new RequestError( `Request failed: ${response.status} ${response.statusText}`, response.status, response.statusText, responseBody ); // Check if we should retry if (retryOn.includes(response.status) && attempt < retries) { const backoff = calculateBackoff(retryDelay, attempt); log.warn( `Request failed (${response.status}), ` + `retrying in ${backoff}ms (attempt ${attempt + 1}/${retries})` ); await delay(backoff); continue; } throw error; } // Success - return response return response; } catch (error) { clearTimeout(timeoutId); // Re-throw RequestError (already formatted) if (error instanceof RequestError) { lastError = error; // Check if we should retry if (error.isRetryable(retryOn) && attempt < retries) { const backoff = calculateBackoff(retryDelay, attempt); log.warn( `Request error (${error.status}), ` + `retrying in ${backoff}ms (attempt ${attempt + 1}/${retries})` ); await delay(backoff); continue; } throw error; } // Handle AbortError (timeout) if (error instanceof Error && error.name === 'AbortError') { const timeoutError = new RequestError( `Request timeout after ${timeout}ms`, 408, 'Request Timeout' ); // Retry on timeout if (attempt < retries) { const backoff = calculateBackoff(retryDelay, attempt); log.warn( `Request timed out, ` + `retrying in ${backoff}ms (attempt ${attempt + 1}/${retries})` ); await delay(backoff); lastError = timeoutError; continue; } throw timeoutError; } // Handle cancellation if (error instanceof RequestCancelledError) { throw error; } // Unknown error - wrap and throw throw new RequestError( error instanceof Error ? error.message : 'Unknown error', 0, 'Unknown Error', error instanceof Error ? error.stack : String(error) ); } } // All retries exhausted throw lastError || new RequestError('All retry attempts exhausted', 0, 'Retry Exhausted'); } /** * Execute a request and parse JSON response. * Combines requestWithRetry with JSON parsing. * * @param url Request URL * @param options Fetch options * @param config Request configuration * @returns Parsed JSON response */ export async function requestJson( url: string, options: RequestInit = {}, config: RequestConfig = {} ): Promise { const response = await requestWithRetry(url, options, config); try { return await response.json(); } catch (error) { throw new RequestError( `Failed to parse JSON response: ${error instanceof Error ? error.message : 'Unknown error'}`, 0, 'Parse Error', await response.text().catch(() => '') ); } } // === Request Manager (Cancellation Support) === /** * Manages multiple concurrent requests with cancellation support. * Provides centralized control over request lifecycle. */ export class RequestManager { private controllers = new Map(); private requestConfigs = new Map(); /** * Create a new request with an ID for tracking. * Returns the AbortController for signal attachment. * * @param id Unique request identifier * @param config Optional request configuration * @returns AbortController for the request */ createRequest(id: string, config?: RequestConfig): AbortController { // Cancel existing request with same ID if (this.controllers.has(id)) { this.cancelRequest(id); } const controller = new AbortController(); this.controllers.set(id, controller); if (config) { this.requestConfigs.set(id, config); } return controller; } /** * Execute a managed request with automatic tracking. * The request will be automatically removed when complete. * * @param id Unique request identifier * @param url Request URL * @param options Fetch options * @param config Request configuration * @returns Response promise */ async executeManaged( id: string, url: string, options: RequestInit = {}, config: RequestConfig = {} ): Promise { const controller = this.createRequest(id, config); try { const response = await requestWithRetry( url, { ...options, signal: controller.signal, }, config ); // Clean up on success this.controllers.delete(id); this.requestConfigs.delete(id); return response; } catch (error) { // Clean up on error this.controllers.delete(id); this.requestConfigs.delete(id); throw error; } } /** * Execute a managed JSON request with automatic tracking. * * @param id Unique request identifier * @param url Request URL * @param options Fetch options * @param config Request configuration * @returns Parsed JSON response */ async executeManagedJson( id: string, url: string, options: RequestInit = {}, config: RequestConfig = {} ): Promise { const response = await this.executeManaged(id, url, options, config); try { return await response.json(); } catch (error) { throw new RequestError( `Failed to parse JSON response: ${error instanceof Error ? error.message : 'Unknown error'}`, 0, 'Parse Error', await response.text().catch(() => '') ); } } /** * Cancel a specific request by ID. * * @param id Request identifier * @returns true if request was cancelled, false if not found */ cancelRequest(id: string): boolean { const controller = this.controllers.get(id); if (controller) { controller.abort(); this.controllers.delete(id); this.requestConfigs.delete(id); return true; } return false; } /** * Check if a request is currently in progress. * * @param id Request identifier * @returns true if request is active */ isRequestActive(id: string): boolean { return this.controllers.has(id); } /** * Get all active request IDs. * * @returns Array of active request IDs */ getActiveRequestIds(): string[] { return Array.from(this.controllers.keys()); } /** * Cancel all active requests. */ cancelAll(): void { this.controllers.forEach((controller, id) => { controller.abort(); log.debug(`Cancelled request: ${id}`); }); this.controllers.clear(); this.requestConfigs.clear(); } /** * Get the number of active requests. */ get activeCount(): number { return this.controllers.size; } } // === Default Request Manager Instance === /** * Global request manager instance for application-wide request tracking. * Use this for simple cases; create new instances for isolated contexts. */ export const globalRequestManager = new RequestManager(); // === Convenience Functions === /** * Create a GET request with retry support. */ export async function get( url: string, headers?: HeadersInit, config?: RequestConfig ): Promise { return requestWithRetry(url, { method: 'GET', headers }, config); } /** * Create a POST request with retry support. */ export async function post( url: string, body?: unknown, headers?: HeadersInit, config?: RequestConfig ): Promise { return requestWithRetry( url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers, }, body: body ? JSON.stringify(body) : undefined, }, config ); } /** * Create a PUT request with retry support. */ export async function put( url: string, body?: unknown, headers?: HeadersInit, config?: RequestConfig ): Promise { return requestWithRetry( url, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...headers, }, body: body ? JSON.stringify(body) : undefined, }, config ); } /** * Create a DELETE request with retry support. */ export async function del( url: string, headers?: HeadersInit, config?: RequestConfig ): Promise { return requestWithRetry(url, { method: 'DELETE', headers }, config); } /** * Create a PATCH request with retry support. */ export async function patch( url: string, body?: unknown, headers?: HeadersInit, config?: RequestConfig ): Promise { return requestWithRetry( url, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...headers, }, body: body ? JSON.stringify(body) : undefined, }, config ); } export default { requestWithRetry, requestJson, RequestManager, globalRequestManager, RequestError, RequestCancelledError, get, post, put, del, patch, DEFAULT_REQUEST_CONFIG, };