Files
Yvv 403b94fa2c Sprint 5 : integration et production -- securite, performance, API publique, documentation
Backend: rate limiter, security headers, blockchain cache service avec RPC,
public API (7 endpoints read-only), WebSocket auth + heartbeat, DB connection
pooling, structured logging, health check DB. Frontend: API retry/timeout,
WebSocket auth + heartbeat + typed events, notifications toast, mobile hamburger
+ drawer, error boundary, offline banner, loading skeletons, dashboard enrichi.
Documentation: guides utilisateur complets (demarrage, vote, sanctuaire, FAQ 30+),
guide deploiement, politique securite. 123 tests, 155 fichiers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:12:50 +01:00

197 lines
6.3 KiB
TypeScript

/**
* 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<number, string> = {
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<string, any> {
/** 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<void> {
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<T>(path: string, options: ApiOptions = {}): Promise<T> {
const {
timeout = DEFAULT_TIMEOUT_MS,
signal: externalSignal,
noRetry = false,
...fetchOptions
} = options
const headers: Record<string, string> = {}
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<T>(`${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 }
}