202 lines
5.2 KiB
JavaScript
202 lines
5.2 KiB
JavaScript
/**
|
|
* 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<any>} 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'
|
|
});
|