/** * Composable for making authenticated API calls to the Glibredecision backend. * * Uses the runtime config `apiBase` and automatically injects the Bearer token * from the auth store when available. * * Production-grade features: * - Error interceptor with user-friendly French messages * - Retry logic with exponential backoff for 5xx errors * - Request timeout (30s default) * - AbortController support for cancellation */ /** Map of HTTP status codes to user-friendly French error messages. */ const HTTP_ERROR_MESSAGES: Record = { 401: 'Session expirée, veuillez vous reconnecter', 403: 'Accès non autorisé', 404: 'Ressource introuvable', 409: 'Conflit avec une ressource existante', 422: 'Données invalides', 429: 'Trop de requêtes, veuillez patienter', 500: 'Erreur serveur, veuillez réessayer', 502: 'Service temporairement indisponible', 503: 'Service en maintenance, veuillez réessayer', 504: 'Délai de réponse dépassé', } /** Default request timeout in milliseconds. */ const DEFAULT_TIMEOUT_MS = 30_000 /** Maximum number of retry attempts for 5xx errors. */ const MAX_RETRIES = 3 /** Base delay for exponential backoff in milliseconds. */ const BASE_BACKOFF_MS = 1_000 export interface ApiOptions extends Record { /** Custom timeout in milliseconds (default: 30000). */ timeout?: number /** External AbortController for request cancellation. */ signal?: AbortSignal /** Disable automatic retry for this request. */ noRetry?: boolean } export interface ApiError { status: number message: string detail?: string } /** * Resolve a user-friendly error message from an HTTP status code. */ function resolveErrorMessage(status: number, fallback?: string): string { return HTTP_ERROR_MESSAGES[status] || fallback || 'Une erreur inattendue est survenue' } /** * Wait for the specified duration in milliseconds. */ function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } /** * Determine whether an HTTP status code is retryable (5xx server errors). */ function isRetryable(status: number): boolean { return status >= 500 && status < 600 } export function useApi() { const config = useRuntimeConfig() const auth = useAuthStore() /** * Perform a typed fetch against the backend API. * * @param path - API path relative to apiBase, e.g. "/documents" * @param options - $fetch options (method, body, query, headers, timeout, signal, noRetry) * @returns Typed response * @throws ApiError with status, message, and optional detail */ async function $api(path: string, options: ApiOptions = {}): Promise { const { timeout = DEFAULT_TIMEOUT_MS, signal: externalSignal, noRetry = false, ...fetchOptions } = options const headers: Record = {} if (auth.token) { headers.Authorization = `Bearer ${auth.token}` } const maxAttempts = noRetry ? 1 : MAX_RETRIES let lastError: any = null for (let attempt = 1; attempt <= maxAttempts; attempt++) { // Create an AbortController for timeout management const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) // If an external signal is provided, forward its abort let externalAbortHandler: (() => void) | null = null if (externalSignal) { if (externalSignal.aborted) { clearTimeout(timeoutId) throw _createApiError(0, 'Requête annulée') } externalAbortHandler = () => controller.abort() externalSignal.addEventListener('abort', externalAbortHandler, { once: true }) } try { const result = await $fetch(`${config.public.apiBase}${path}`, { ...fetchOptions, headers: { ...headers, ...fetchOptions.headers }, signal: controller.signal, }) return result } catch (err: any) { lastError = err const status = err?.response?.status || err?.status || 0 // Handle 401: auto-logout for expired sessions if (status === 401 && auth.token) { auth.token = null auth.identity = null auth._clearToken() navigateTo('/login') } // Handle abort (timeout or external cancellation) if (err?.name === 'AbortError' || controller.signal.aborted) { if (externalSignal?.aborted) { throw _createApiError(0, 'Requête annulée') } throw _createApiError(0, 'Délai de réponse dépassé') } // Retry only for 5xx errors and if we have remaining attempts if (isRetryable(status) && attempt < maxAttempts) { const backoffMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1) await delay(backoffMs) continue } // Build and throw a structured error const detail = err?.data?.detail || err?.message || undefined const message = resolveErrorMessage(status, detail) throw _createApiError(status, message, detail) } finally { clearTimeout(timeoutId) if (externalAbortHandler && externalSignal) { externalSignal.removeEventListener('abort', externalAbortHandler) } } } // Fallback: should not reach here, but handle gracefully const fallbackStatus = lastError?.response?.status || lastError?.status || 0 const fallbackDetail = lastError?.data?.detail || lastError?.message || undefined throw _createApiError(fallbackStatus, resolveErrorMessage(fallbackStatus, fallbackDetail), fallbackDetail) } /** * Create a structured ApiError object. */ function _createApiError(status: number, message: string, detail?: string): ApiError { const error: ApiError = { status, message } if (detail) error.detail = detail return error } /** * Create an AbortController for manual request cancellation. * Useful for component unmount cleanup. * * @example * const { controller, signal } = createAbortController() * await $api('/documents', { signal }) * // On unmount: * controller.abort() */ function createAbortController() { const controller = new AbortController() return { controller, signal: controller.signal } } return { $api, createAbortController } }