1+ "use client" ;
2+
3+ import { useCallback , useRef } from "react" ;
4+ import axios , { AxiosError , AxiosRequestConfig } from "axios" ;
5+
6+ export const IDEMPOTENT_METHODS = [ "GET" , "HEAD" , "OPTIONS" ] ;
7+
8+ export interface RetryOptions {
9+ maxRetries ?: number ;
10+ initialDelayMs ?: number ;
11+ maxDelayMs ?: number ;
12+ backoffMultiplier ?: number ;
13+ onRetry ?: ( attempt : number , error : AxiosError , delayMs : number ) => void ;
14+ onMaxRetriesExceeded ?: ( error : AxiosError ) => void ;
15+ }
16+
17+ export interface RequestOptions extends RetryOptions {
18+ silent ?: boolean ;
19+ }
20+
21+ function calculateBackoff ( delay : number , multiplier : number , jitter : number , maxDelay : number ) : number {
22+ const next = Math . min ( delay * multiplier , maxDelay ) ;
23+ return Math . round ( next + jitter * next ) ;
24+ }
25+
26+ export function useRequestRetry ( ) {
27+ const abortControllersRef = useRef < Map < string , AbortController > > ( new Map ( ) ) ;
28+
29+ const isIdempotent = useCallback ( ( method ?: string ) : boolean => {
30+ return IDEMPOTENT_METHODS . includes ( ( method ?? "" ) . toUpperCase ( ) ) ;
31+ } , [ ] ) ;
32+
33+ const requestWithRetry = useCallback (
34+ async < T > (
35+ config : AxiosRequestConfig ,
36+ options : RequestOptions = { }
37+ ) : Promise < T > => {
38+ const {
39+ maxRetries = 3 ,
40+ initialDelayMs = 1000 ,
41+ maxDelayMs = 30000 ,
42+ backoffMultiplier = 2 ,
43+ onRetry,
44+ onMaxRetriesExceeded,
45+ silent = false ,
46+ } = options ;
47+
48+ let attempt = 0 ;
49+ let delayMs = initialDelayMs ;
50+ let lastError : AxiosError | undefined ;
51+
52+ while ( attempt <= maxRetries ) {
53+ try {
54+ const response = await axios . request < T > ( { ...config } ) ;
55+ return response . data ;
56+ } catch ( error ) {
57+ if ( ! axios . isAxiosError ( error ) ) {
58+ throw error ;
59+ }
60+
61+ lastError = error as AxiosError ;
62+
63+ if ( attempt === maxRetries ) {
64+ if ( ! silent ) {
65+ console . error ( `[RequestRetry] Max retries (${ maxRetries } ) exceeded:` , error ) ;
66+ }
67+ onMaxRetriesExceeded ?.( lastError ) ;
68+ throw error ;
69+ }
70+
71+ const isServerError = ( error . response ?. status ?? 0 ) >= 500 ;
72+ const isNetworkError = ! error . response ;
73+ const isTimeout = axios . isAxiosError ( error ) && error . code === "ECONNABORTED" ;
74+
75+ if ( ! isServerError && ! isNetworkError && ! isTimeout ) {
76+ if ( ! silent ) {
77+ console . warn ( `[RequestRetry] Non-retryable error (${ error . response ?. status } ):` , error . message ) ;
78+ }
79+ throw error ;
80+ }
81+
82+ const jitter = Math . random ( ) * 0.2 ;
83+ onRetry ?.( attempt + 1 , lastError , delayMs ) ;
84+
85+ await new Promise ( ( resolve ) => setTimeout ( resolve , delayMs ) ) ;
86+ delayMs = calculateBackoff ( delayMs , backoffMultiplier , jitter , maxDelayMs ) ;
87+ attempt ++ ;
88+ }
89+ }
90+
91+ throw lastError ;
92+ } ,
93+ [ ]
94+ ) ;
95+
96+ const abortAll = useCallback ( ( ) => {
97+ abortControllersRef . current . forEach ( ( controller ) => controller . abort ( ) ) ;
98+ abortControllersRef . current . clear ( ) ;
99+ } , [ ] ) ;
100+
101+ const abort = useCallback ( ( key : string ) => {
102+ const controller = abortControllersRef . current . get ( key ) ;
103+ if ( controller ) {
104+ controller . abort ( ) ;
105+ abortControllersRef . current . delete ( key ) ;
106+ }
107+ } , [ ] ) ;
108+
109+ const trackedRequest = useCallback (
110+ async < T > (
111+ key : string ,
112+ config : AxiosRequestConfig ,
113+ options : RequestOptions = { }
114+ ) : Promise < T > => {
115+ const controller = new AbortController ( ) ;
116+ abortControllersRef . current . set ( key , controller ) ;
117+ try {
118+ return await requestWithRetry < T > ( { ...config , signal : controller . signal } , options ) ;
119+ } finally {
120+ abortControllersRef . current . delete ( key ) ;
121+ }
122+ } ,
123+ [ requestWithRetry ]
124+ ) ;
125+
126+ return { requestWithRetry, trackedRequest, isIdempotent, abortAll, abort } ;
127+ }
128+
129+ export function isRetryableError ( error : unknown ) : boolean {
130+ if ( ! axios . isAxiosError ( error ) ) return false ;
131+ const status = error . response ?. status ?? 0 ;
132+ return status >= 500 || status === 0 || error . code === "ECONNABORTED" ;
133+ }
0 commit comments