/** * API Client Utility * * Centralized fetch wrapper with: * - Automatic error handling * - Retry logic with exponential backoff * - Request/response interceptors * - Request deduplication * - Timeout support * * @example * const client = createApiClient({ baseURL: '/api' }); * const data = await client.get('/users'); */ const activeRequests = new Map(); /** * Create an API client with configuration * @param {Object} config - Client configuration * @returns {Object} API client with methods */ export function createApiClient(config = {}) { const { baseURL = '', timeout = 30000, maxRetries = 3, retryDelay = 1000, headers: defaultHeaders = {}, onRequest = null, onResponse = null, onError = null } = config; /** * Make HTTP request * @param {string} url - Request URL * @param {Object} options - Fetch options * @returns {Promise} Response data */ async function request(url, options = {}) { const fullURL = url.startsWith('http') ? url : `${baseURL}${url}`; const requestKey = `${options.method || 'GET'}:${fullURL}`; // Check for duplicate request if (options.deduplicate !== false && activeRequests.has(requestKey)) { return activeRequests.get(requestKey); } const requestPromise = makeRequest(fullURL, options); // Store active request if (options.deduplicate !== false) { activeRequests.set(requestKey, requestPromise); } try { const result = await requestPromise; return result; } finally { activeRequests.delete(requestKey); } } /** * Make the actual HTTP request with retries */ async function makeRequest(url, options) { const { retries = maxRetries, ...fetchOptions } = options; // Merge headers const headers = { 'Content-Type': 'application/json', ...defaultHeaders, ...fetchOptions.headers }; // Create abort controller for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); let lastError; let attempt = 0; while (attempt <= retries) { try { // Call request interceptor let requestOptions = { ...fetchOptions, headers, signal: controller.signal }; if (onRequest) { requestOptions = (await onRequest(url, requestOptions)) || requestOptions; } const response = await fetch(url, requestOptions); clearTimeout(timeoutId); // Call response interceptor let processedResponse = response; if (onResponse) { processedResponse = (await onResponse(response.clone())) || response; } // Handle HTTP errors if (!processedResponse.ok) { const error = new Error( `HTTP ${processedResponse.status}: ${processedResponse.statusText}` ); error.status = processedResponse.status; error.response = processedResponse; // Try to parse error body try { const contentType = processedResponse.headers.get('content-type'); if (contentType?.includes('application/json')) { error.data = await processedResponse.json(); } else { error.data = await processedResponse.text(); } } catch (e) { // Ignore parse errors } throw error; } // Parse response const contentType = processedResponse.headers.get('content-type'); if (contentType?.includes('application/json')) { return await processedResponse.json(); } return await processedResponse.text(); } catch (error) { clearTimeout(timeoutId); lastError = error; // Don't retry on abort if (error.name === 'AbortError') { const timeoutError = new Error('Request timeout'); timeoutError.isTimeout = true; throw timeoutError; } // Don't retry on 4xx errors (client errors) if (error.status && error.status >= 400 && error.status < 500) { throw error; } attempt++; // If more retries remaining, wait before retrying if (attempt <= retries) { await new Promise(resolve => setTimeout(resolve, retryDelay * attempt) ); } } } // All retries exhausted if (onError) { onError(lastError); } throw lastError; } // Convenience methods return { request, get: (url, options = {}) => request(url, { ...options, method: 'GET' }), post: (url, data, options = {}) => request(url, { ...options, method: 'POST', body: JSON.stringify(data) }), put: (url, data, options = {}) => request(url, { ...options, method: 'PUT', body: JSON.stringify(data) }), patch: (url, data, options = {}) => request(url, { ...options, method: 'PATCH', body: JSON.stringify(data) }), delete: (url, options = {}) => request(url, { ...options, method: 'DELETE' }) }; } // Export default client instance export const apiClient = createApiClient({ baseURL: '/api' });