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'
|
| 'INTERNAL_ERROR'
|
||||||
|
|
||||||
export type FrontendErrorCode = 'TIMEOUT' | 'NETWORK_ERROR' | 'PARSE_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