feat(shared): auth-client Supabase + api-client apiFetch avec retry/timeout

This commit is contained in:
Hermann_Kitio 2026-04-17 17:33:23 +03:00
parent 476dfeeb08
commit 7f552dcdd1
3 changed files with 194 additions and 0 deletions

View file

@ -0,0 +1,164 @@
import { env } from '@/shared/config/env'
import type {
ApiError,
ClientError,
FetchError,
FrontendErrorCode,
} from '@/shared/types/api'
import { getAccessToken } from './auth-client'
import { logger } from './logger'
const API_VERSION = '1.0'
const DEFAULT_TIMEOUT_MS = 5000
const DEFAULT_RETRY = { max: 2, baseDelayMs: 250 }
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'PUT', 'DELETE'])
export interface ApiFetchOptions {
method?: string
headers?: Record<string, string>
body?: unknown
timeoutMs?: number
retry?: { max: number; baseDelayMs: number }
}
export async function apiFetch<T>(
path: string,
options: ApiFetchOptions = {},
): Promise<T> {
const method = (options.method ?? 'GET').toUpperCase()
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const retry =
options.retry ?? (IDEMPOTENT_METHODS.has(method) ? DEFAULT_RETRY : { max: 0, baseDelayMs: 0 })
const url = path.startsWith('http') ? path : `${env.VITE_API_URL}${path}`
const headers: Record<string, string> = {
'X-API-Version': API_VERSION,
...(options.headers ?? {}),
}
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json'
}
if (!path.startsWith('/health')) {
const token = await getAccessToken()
if (token) {
headers.Authorization = `Bearer ${token}`
}
}
const init: RequestInit = {
method,
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
}
let attempt = 0
while (true) {
logger.info('API request', { method, path, attempt })
try {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(timeoutMs),
})
if (response.ok) {
try {
return (await response.json()) as T
} catch {
const err = clientError('PARSE_ERROR', 'Réponse serveur non-JSON')
logger.error('API parse failure', { method, path })
throw err
}
}
if (response.status >= 500 && attempt < retry.max) {
const delay = retry.baseDelayMs * Math.pow(2, attempt)
logger.warn('API retry', {
method,
path,
status: response.status,
attempt,
nextDelayMs: delay,
})
await sleep(delay)
attempt++
continue
}
const apiErr = await parseApiError(response)
logger.error('API failure', {
method,
path,
code: apiErr.code,
status: response.status,
attempt,
})
throw apiErr
} catch (err) {
if (isFetchError(err)) {
throw err
}
if (err instanceof Error && err.name === 'TimeoutError') {
const clientErr = clientError('TIMEOUT', `Requête interrompue après ${timeoutMs}ms`)
logger.error('API timeout', { method, path, attempt, timeoutMs })
throw clientErr
}
if (attempt < retry.max) {
const delay = retry.baseDelayMs * Math.pow(2, attempt)
logger.warn('API network retry', { method, path, attempt, nextDelayMs: delay })
await sleep(delay)
attempt++
continue
}
const clientErr = clientError('NETWORK_ERROR', 'Erreur réseau')
logger.error('API network failure', { method, path, attempt })
throw clientErr
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function clientError(code: FrontendErrorCode, message: string): ClientError {
return { error: true, code, message }
}
function isFetchError(err: unknown): err is FetchError {
return (
typeof err === 'object' &&
err !== null &&
'error' in err &&
(err as { error: unknown }).error === true &&
'code' in err
)
}
async function parseApiError(response: Response): Promise<ApiError> {
try {
const body = await response.json()
if (
body &&
typeof body === 'object' &&
body.error === true &&
typeof body.code === 'string'
) {
return body as ApiError
}
} catch {
// corps non-JSON — fallback INTERNAL_ERROR
}
return {
error: true,
code: 'INTERNAL_ERROR',
message: `HTTP ${response.status} sans corps valide`,
status: response.status,
}
}

View file

@ -0,0 +1,22 @@
import { createClient } from '@supabase/supabase-js'
import { env } from '@/shared/config/env'
import { logger } from './logger'
const supabase = createClient(env.VITE_SUPABASE_URL, env.VITE_SUPABASE_ANON_KEY)
export async function getAccessToken(): Promise<string | null> {
const { data, error } = await supabase.auth.getSession()
if (error) {
logger.error('Auth session fetch failed', { name: error.name })
return null
}
return data.session?.access_token ?? null
}
export async function signIn(email: string, password: string) {
return supabase.auth.signInWithPassword({ email, password })
}
export async function signOut() {
return supabase.auth.signOut()
}

View file

@ -21,3 +21,11 @@ export type ApiErrorCode =
| 'INTERNAL_ERROR'
export type FrontendErrorCode = 'TIMEOUT' | 'NETWORK_ERROR' | 'PARSE_ERROR'
export interface ClientError {
error: true
code: FrontendErrorCode
message: string
}
export type FetchError = ApiError | ClientError