@@ -4,6 +4,42 @@ import { ErrorType, ErrorInfo } from '@/utils/errorUtils';
44export type { ErrorInfo } ;
55
66const DEFAULT_TIMEOUT_MS = 10_000 ;
7+ const MAX_RETRIES = 3 ;
8+ const RETRY_DELAY_MS = 1000 ;
9+
10+ /**
11+ * Request interceptor function type
12+ */
13+ export type RequestInterceptor = ( config : RequestConfig ) => Promise < RequestConfig > | RequestConfig ;
14+
15+ /**
16+ * Response interceptor function type
17+ */
18+ export type ResponseInterceptor < T = unknown > = ( response : T ) => Promise < T > | T ;
19+
20+ /**
21+ * Error interceptor function type
22+ */
23+ export type ErrorInterceptor = ( error : Error ) => Promise < void > | void ;
24+
25+ /**
26+ * Request configuration for the API client
27+ */
28+ export interface RequestConfig extends RequestInit {
29+ url : string ;
30+ retries ?: number ;
31+ timeout ?: number ;
32+ }
33+
34+ /**
35+ * API Client configuration options
36+ */
37+ export interface ApiClientConfig {
38+ baseURL ?: string ;
39+ timeout ?: number ;
40+ maxRetries ?: number ;
41+ retryDelay ?: number ;
42+ }
743
844function statusToErrorType ( status : number ) : ErrorType {
945 if ( status === 401 ) return ErrorType . AUTHENTICATION ;
@@ -21,68 +57,233 @@ function statusToUserMessage(status: number): string {
2157 return 'There was a problem with your request.' ;
2258}
2359
24- async function request < T > ( url : string , options : RequestInit = { } ) : Promise < T > {
25- const controller = new AbortController ( ) ;
26- const timer = setTimeout ( ( ) => controller . abort ( ) , DEFAULT_TIMEOUT_MS ) ;
60+ /**
61+ * Should retry on specific status codes
62+ */
63+ function shouldRetry ( status : number , attempt : number , maxRetries : number ) : boolean {
64+ if ( attempt >= maxRetries ) return false ;
65+ // Retry on 408, 429, 500, 502, 503, 504
66+ return [ 408 , 429 , 500 , 502 , 503 , 504 ] . includes ( status ) ;
67+ }
2768
28- const token = typeof window !== 'undefined' ? localStorage . getItem ( 'token' ) : null ;
69+ /**
70+ * Calculate exponential backoff delay
71+ */
72+ function getRetryDelay ( attempt : number , baseDelay : number ) : number {
73+ return baseDelay * Math . pow ( 2 , attempt - 1 ) + Math . random ( ) * 1000 ;
74+ }
2975
30- const headers : HeadersInit = {
31- 'Content-Type' : 'application/json' ,
32- ...( token ? { Authorization : `Bearer ${ token } ` } : { } ) ,
33- ...( options . headers as Record < string , string > ) ,
34- } ;
76+ /**
77+ * Unified API Client with interceptors and retry logic
78+ */
79+ class ApiClientImpl {
80+ private config : Required < ApiClientConfig > ;
81+ private requestInterceptors : RequestInterceptor [ ] = [ ] ;
82+ private responseInterceptors : ResponseInterceptor [ ] = [ ] ;
83+ private errorInterceptors : ErrorInterceptor [ ] = [ ] ;
3584
36- try {
37- const response = await fetch ( url , {
38- ...options ,
39- headers,
40- signal : controller . signal ,
41- } ) ;
85+ constructor ( config : ApiClientConfig = { } ) {
86+ this . config = {
87+ baseURL : config . baseURL || '' ,
88+ timeout : config . timeout || DEFAULT_TIMEOUT_MS ,
89+ maxRetries : config . maxRetries || MAX_RETRIES ,
90+ retryDelay : config . retryDelay || RETRY_DELAY_MS ,
91+ } ;
92+ }
4293
43- clearTimeout ( timer ) ;
94+ /**
95+ * Add a request interceptor
96+ */
97+ addRequestInterceptor ( interceptor : RequestInterceptor ) : void {
98+ this . requestInterceptors . push ( interceptor ) ;
99+ }
44100
45- if ( ! response . ok ) {
46- let message = response . statusText ;
47- try {
48- const body = await response . json ( ) ;
49- message = body ?. message ?? message ;
50- } catch {
51- // ignore parse errors
52- }
53- throw new ApiError (
54- statusToErrorType ( response . status ) ,
55- message ,
56- statusToUserMessage ( response . status ) ,
57- response . status ,
58- ) ;
101+ /**
102+ * Add a response interceptor
103+ */
104+ addResponseInterceptor ( interceptor : ResponseInterceptor ) : void {
105+ this . responseInterceptors . push ( interceptor ) ;
106+ }
107+
108+ /**
109+ * Add an error interceptor
110+ */
111+ addErrorInterceptor ( interceptor : ErrorInterceptor ) : void {
112+ this . errorInterceptors . push ( interceptor ) ;
113+ }
114+
115+ /**
116+ * Apply all request interceptors
117+ */
118+ private async applyRequestInterceptors ( config : RequestConfig ) : Promise < RequestConfig > {
119+ let processedConfig = config ;
120+ for ( const interceptor of this . requestInterceptors ) {
121+ processedConfig = await interceptor ( processedConfig ) ;
59122 }
123+ return processedConfig ;
124+ }
60125
61- return response . json ( ) as Promise < T > ;
62- } catch ( err ) {
63- clearTimeout ( timer ) ;
64- if ( err instanceof ApiError ) throw err ;
65- throw parseApiError ( err ) ;
126+ /**
127+ * Apply all response interceptors
128+ */
129+ private async applyResponseInterceptors < T > ( response : T ) : Promise < T > {
130+ let processedResponse = response ;
131+ for ( const interceptor of this . responseInterceptors ) {
132+ processedResponse = await interceptor ( processedResponse ) ;
133+ }
134+ return processedResponse ;
66135 }
67- }
68136
69- export const apiClient = {
70- get : < T > ( url : string , options ?: RequestInit ) => request < T > ( url , { ...options , method : 'GET' } ) ,
137+ /**
138+ * Apply all error interceptors
139+ */
140+ private async applyErrorInterceptors ( error : Error ) : Promise < void > {
141+ for ( const interceptor of this . errorInterceptors ) {
142+ await interceptor ( error ) ;
143+ }
144+ }
145+
146+ /**
147+ * Get authentication token
148+ */
149+ private getToken ( ) : string | null {
150+ if ( typeof window === 'undefined' ) return null ;
151+ return localStorage . getItem ( 'token' ) ;
152+ }
153+
154+ /**
155+ * Make HTTP request with retry logic
156+ */
157+ private async requestWithRetry < T > ( config : RequestConfig , attempt = 1 ) : Promise < T > {
158+ const controller = new AbortController ( ) ;
159+ const timeout = config . timeout || this . config . timeout ;
160+ const timer = setTimeout ( ( ) => controller . abort ( ) , timeout ) ;
161+ const maxRetries = config . retries ?? this . config . maxRetries ;
162+
163+ const token = this . getToken ( ) ;
164+ const headers : HeadersInit = {
165+ 'Content-Type' : 'application/json' ,
166+ ...( token ? { Authorization : `Bearer ${ token } ` } : { } ) ,
167+ ...( config . headers as Record < string , string > ) ,
168+ } ;
169+
170+ try {
171+ // Apply request interceptors
172+ const processedConfig = await this . applyRequestInterceptors ( {
173+ ...config ,
174+ headers,
175+ signal : controller . signal ,
176+ } ) ;
177+
178+ const url = this . config . baseURL
179+ ? `${ this . config . baseURL } ${ config . url } `
180+ : config . url ;
71181
72- post : < T > ( url : string , body : unknown , options ?: RequestInit ) =>
73- request < T > ( url , {
182+ const response = await fetch ( url , processedConfig ) ;
183+ clearTimeout ( timer ) ;
184+
185+ if ( ! response . ok ) {
186+ // Check if we should retry
187+ if ( shouldRetry ( response . status , attempt , maxRetries ) ) {
188+ const delay = getRetryDelay ( attempt , this . config . retryDelay ) ;
189+ await new Promise ( ( resolve ) => setTimeout ( resolve , delay ) ) ;
190+ return this . requestWithRetry < T > ( config , attempt + 1 ) ;
191+ }
192+
193+ let message = response . statusText ;
194+ try {
195+ const body = await response . json ( ) ;
196+ message = body ?. message ?? message ;
197+ } catch {
198+ // ignore parse errors
199+ }
200+ throw new ApiError (
201+ statusToErrorType ( response . status ) ,
202+ message ,
203+ statusToUserMessage ( response . status ) ,
204+ response . status ,
205+ ) ;
206+ }
207+
208+ const data = ( await response . json ( ) ) as T ;
209+
210+ // Apply response interceptors
211+ const processedResponse = await this . applyResponseInterceptors ( data ) ;
212+ return processedResponse ;
213+ } catch ( err ) {
214+ clearTimeout ( timer ) ;
215+
216+ const error = err instanceof Error ? err : new Error ( 'Unknown error occurred' ) ;
217+
218+ // Apply error interceptors
219+ await this . applyErrorInterceptors ( error ) ;
220+
221+ if ( err instanceof ApiError ) throw err ;
222+ throw parseApiError ( err ) ;
223+ }
224+ }
225+
226+ /**
227+ * GET request
228+ */
229+ async get < T > ( url : string , options ?: RequestInit ) : Promise < T > {
230+ return this . requestWithRetry < T > ( {
74231 ...options ,
232+ url,
233+ method : 'GET' ,
234+ } ) ;
235+ }
236+
237+ /**
238+ * POST request
239+ */
240+ async post < T > ( url : string , body ?: unknown , options ?: RequestInit ) : Promise < T > {
241+ return this . requestWithRetry < T > ( {
242+ ...options ,
243+ url,
75244 method : 'POST' ,
76245 body : JSON . stringify ( body ) ,
77- } ) ,
246+ } ) ;
247+ }
78248
79- patch : < T > ( url : string , body : unknown , options ?: RequestInit ) =>
80- request < T > ( url , {
249+ /**
250+ * PATCH request
251+ */
252+ async patch < T > ( url : string , body ?: unknown , options ?: RequestInit ) : Promise < T > {
253+ return this . requestWithRetry < T > ( {
81254 ...options ,
255+ url,
82256 method : 'PATCH' ,
83257 body : JSON . stringify ( body ) ,
84- } ) ,
258+ } ) ;
259+ }
260+
261+ /**
262+ * PUT request
263+ */
264+ async put < T > ( url : string , body ?: unknown , options ?: RequestInit ) : Promise < T > {
265+ return this . requestWithRetry < T > ( {
266+ ...options ,
267+ url,
268+ method : 'PUT' ,
269+ body : JSON . stringify ( body ) ,
270+ } ) ;
271+ }
272+
273+ /**
274+ * DELETE request
275+ */
276+ async delete < T > ( url : string , options ?: RequestInit ) : Promise < T > {
277+ return this . requestWithRetry < T > ( {
278+ ...options ,
279+ url,
280+ method : 'DELETE' ,
281+ } ) ;
282+ }
283+ }
284+
285+ // Create singleton instance
286+ export const apiClient = new ApiClientImpl ( ) ;
85287
86- delete : < T > ( url : string , options ?: RequestInit ) =>
87- request < T > ( url , { ...options , method : 'DELETE' } ) ,
88- } ;
288+ // Export types for external use
289+ export type { ApiClientImpl } ;
0 commit comments