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>
This commit is contained in:
@@ -3,7 +3,73 @@
|
||||
*
|
||||
* 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()
|
||||
@@ -12,20 +78,119 @@ export function useApi() {
|
||||
* 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, etc.)
|
||||
* @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: Record<string, any> = {}): Promise<T> {
|
||||
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}`
|
||||
}
|
||||
|
||||
return await $fetch<T>(`${config.public.apiBase}${path}`, {
|
||||
...options,
|
||||
headers: { ...headers, ...options.headers },
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
return { $api }
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user