diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index eecf2b40..f1372e86 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -1,3 +1,4 @@ +// frontend/lib/api/client.ts import { OfflineActionQueuedError, isLikelyOfflineError, @@ -6,15 +7,13 @@ import { } from '@/lib/offline'; const API_BASE_URL = - process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001/api/v1'; - -const DEFAULT_RETRY_CONFIG: RetryConfig = { - maxRetries: 3, - baseDelay: 1000, - maxDelay: 10000, - jitter: true, -}; + process.env.NEXT_PUBLIC_API_URL || + process.env.NEXT_PUBLIC_BACKEND_URL || + 'http://localhost:3001/api/v1'; +/* ===================================================== + Retry Configuration +===================================================== */ export interface RetryConfig { maxRetries: number; baseDelay: number; @@ -22,6 +21,16 @@ export interface RetryConfig { jitter: boolean; } +const DEFAULT_RETRY_CONFIG: RetryConfig = { + maxRetries: 3, + baseDelay: 1000, + maxDelay: 10000, + jitter: true, +}; + +/* ===================================================== + Custom API Error +===================================================== */ export class ApiError extends Error { status: number; response: Response; @@ -36,29 +45,32 @@ export class ApiError extends Error { } } +/** Type guard for UI handling */ +export function isApiError(error: unknown): error is ApiError { + return error instanceof ApiError; +} + +/* ===================================================== + URL Resolver +===================================================== */ export function resolveApiUrl(endpoint: string) { return `${API_BASE_URL}${endpoint}`; } +/* ===================================================== + Retry Helpers +===================================================== */ function shouldRetryStatus(status: number): boolean { return status >= 500 || status === 429; } function shouldRetryError(error: unknown): boolean { - if (error instanceof ApiError) { - return shouldRetryStatus(error.status); - } + if (error instanceof ApiError) return shouldRetryStatus(error.status); - if ( - (error instanceof Error && error.name === 'AbortError') || - (typeof error === 'object' && - error !== null && - 'name' in error && - error.name === 'AbortError') - ) { - return false; - } + // Abort should NOT retry + if (error instanceof Error && error.name === 'AbortError') return false; + // Network errors → retry return true; } @@ -68,43 +80,35 @@ function calculateDelay(attempt: number, config: RetryConfig): number { return config.jitter ? delay * (0.5 + Math.random()) : delay; } -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +/* ===================================================== + Error Normalization +===================================================== */ function normalizeError(error: unknown): Error { - if (error instanceof Error) { - return error; - } + if (error instanceof Error) return error; const message = - typeof error === 'object' && - error !== null && - 'message' in error && - typeof error.message === 'string' - ? error.message + typeof error === 'object' && error !== null && 'message' in error && typeof (error as any).message === 'string' + ? (error as any).message : String(error); const normalized = new Error(message); - if ( - typeof error === 'object' && - error !== null && - 'name' in error && - typeof error.name === 'string' - ) { - normalized.name = error.name; + if (typeof error === 'object' && error !== null && 'name' in error && typeof (error as any).name === 'string') { + normalized.name = (error as any).name; } return normalized; } +/* ===================================================== + Response Parsing +===================================================== */ async function parseResponseBody(response: Response): Promise { const contentType = response.headers.get('content-type') || ''; - if (response.status === 204) { - return null; - } + if (response.status === 204) return null; if (contentType.includes('application/json')) { return response.json(); @@ -114,18 +118,35 @@ async function parseResponseBody(response: Response): Promise { return text.length > 0 ? text : null; } -function getErrorMessage(statusText: string, data: unknown): string { - if (data && typeof data === 'object' && 'message' in data && typeof data.message === 'string') { - return data.message; +/* ===================================================== + Friendly Error Messages +===================================================== */ +function getErrorMessage(status: number, statusText: string, data: unknown): string { + if (data && typeof data === 'object' && 'message' in data && typeof (data as any).message === 'string') { + return (data as any).message; } - if (typeof data === 'string' && data.trim().length > 0) { - return data; + switch (status) { + case 400: + return 'Invalid request. Please check your input.'; + case 401: + return 'You are not authenticated. Please login again.'; + case 403: + return 'You do not have permission to perform this action.'; + case 404: + return 'Requested resource was not found.'; + case 429: + return 'Too many requests. Please try again shortly.'; + case 500: + return 'Server error. Please try again later.'; + default: + return `Request failed: ${statusText}`; } - - return `API Error: ${statusText}`; } +/* ===================================================== + Main API Call +===================================================== */ export async function apiCall( endpoint: string, options: RequestInit = {}, @@ -135,6 +156,7 @@ export async function apiCall( let lastError: Error | undefined; const shouldQueue = shouldQueueRequest(options); + // Queue immediately if offline if (shouldQueue && typeof navigator !== 'undefined' && navigator.onLine === false) { const action = queueOfflineAction(endpoint, options); throw new OfflineActionQueuedError( @@ -150,41 +172,36 @@ export async function apiCall( ...options, headers: { 'Content-Type': 'application/json', - ...options.headers, + ...(options.headers || {}), }, }); const data = await parseResponseBody(response); - if (response.ok) { - return data as T; - } + if (response.ok) return data as T; - throw new ApiError( - getErrorMessage(response.statusText, data), - response.status, - response, - data - ); + throw new ApiError(getErrorMessage(response.status, response.statusText, data), response.status, response, data); } catch (error) { lastError = normalizeError(error); + // Debug logging + console.error('[API ERROR]', { endpoint, attempt, error: lastError }); + + // Queue request if offline if (shouldQueue && isLikelyOfflineError(lastError)) { const action = queueOfflineAction(endpoint, options); throw new OfflineActionQueuedError( - 'The request was queued because the network is unavailable.', + 'Network unavailable. Request queued.', endpoint, action.id ); } - if (attempt === config.maxRetries || !shouldRetryError(error)) { - throw lastError; - } + if (attempt === config.maxRetries || !shouldRetryError(error)) throw lastError; await delay(calculateDelay(attempt, config)); } } throw lastError || new Error('API call failed after retries'); -} +} \ No newline at end of file