@@ -13,13 +13,29 @@ const defaultOption: RequestOptions = {
1313type OptionReturnType < Opt , T > = Opt extends { unwrapData : false } ? AxiosResponse < T > : Opt extends { unwrapData : true } ? T : T
1414
1515export type APIClientOptions = {
16- wrapResponseErrors : boolean
16+ wrapResponseErrors : boolean ;
17+ timeout ?: number ;
18+ retryConfig ?: {
19+ maxRetries : number ;
20+ baseDelay : number ;
21+ } ;
1722}
1823
1924export class API {
2025 private axios : AxiosInstance
2126
22- constructor ( readonly accessToken : string , public hackmdAPIEndpointURL : string = "https://api.hackmd.io/v1" , public options : APIClientOptions = { wrapResponseErrors : true } ) {
27+ constructor (
28+ readonly accessToken : string ,
29+ public hackmdAPIEndpointURL : string = "https://api.hackmd.io/v1" ,
30+ public options : APIClientOptions = {
31+ wrapResponseErrors : true ,
32+ timeout : 30000 ,
33+ retryConfig : {
34+ maxRetries : 3 ,
35+ baseDelay : 100 ,
36+ } ,
37+ }
38+ ) {
2339 if ( ! accessToken ) {
2440 throw new HackMDErrors . MissingRequiredArgument ( 'Missing access token when creating HackMD client' )
2541 }
@@ -28,7 +44,8 @@ export class API {
2844 baseURL : hackmdAPIEndpointURL ,
2945 headers :{
3046 "Content-Type" : "application/json" ,
31- }
47+ } ,
48+ timeout : options . timeout
3249 } )
3350
3451 this . axios . interceptors . request . use (
@@ -71,13 +88,51 @@ export class API {
7188 `Received an error response (${ err . response . status } ${ err . response . statusText } ) from HackMD` ,
7289 err . response . status ,
7390 err . response . statusText ,
74- )
91+ ) ;
7592 }
7693 }
77- )
94+ ) ;
95+ }
96+ if ( options . retryConfig ) {
97+ this . createRetryInterceptor ( this . axios , options . retryConfig . maxRetries , options . retryConfig . baseDelay ) ;
7898 }
7999 }
80100
101+ private exponentialBackoff ( retries : number , baseDelay : number ) : number {
102+ return Math . pow ( 2 , retries ) * baseDelay ;
103+ }
104+
105+ private isRetryableError ( error : AxiosError ) : boolean {
106+ return (
107+ ! error . response ||
108+ ( error . response . status >= 500 && error . response . status < 600 ) ||
109+ error . response . status === 429
110+ ) ;
111+ }
112+
113+ private createRetryInterceptor ( axiosInstance : AxiosInstance , maxRetries : number , baseDelay : number ) : void {
114+ let retryCount = 0 ;
115+
116+ axiosInstance . interceptors . response . use (
117+ response => response ,
118+ async error => {
119+ if ( retryCount < maxRetries && this . isRetryableError ( error ) ) {
120+ const remainingCredits = parseInt ( error . response ?. headers [ 'x-ratelimit-userremaining' ] , 10 ) ;
121+
122+ if ( isNaN ( remainingCredits ) || remainingCredits > 0 ) {
123+ retryCount ++ ;
124+ const delay = this . exponentialBackoff ( retryCount , baseDelay ) ;
125+ console . warn ( `Retrying request... attempt #${ retryCount } after delay of ${ delay } ms` ) ;
126+ await new Promise ( resolve => setTimeout ( resolve , delay ) ) ;
127+ return axiosInstance ( error . config ) ;
128+ }
129+ }
130+
131+ retryCount = 0 ; // Reset retry count after a successful request or when not retrying
132+ return Promise . reject ( error ) ;
133+ }
134+ ) ;
135+ }
81136 async getMe < Opt extends RequestOptions > ( options = defaultOption as Opt ) : Promise < OptionReturnType < Opt , GetMe > > {
82137 return this . unwrapData ( this . axios . get < GetMe > ( "me" ) , options . unwrapData ) as unknown as OptionReturnType < Opt , GetMe >
83138 }
0 commit comments