feat(shared): auth-client Supabase + api-client apiFetch avec retry/timeout
This commit is contained in:
parent
476dfeeb08
commit
7f552dcdd1
3 changed files with 194 additions and 0 deletions
164
src/shared/lib/api-client.ts
Normal file
164
src/shared/lib/api-client.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
22
src/shared/lib/auth-client.ts
Normal file
22
src/shared/lib/auth-client.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue