diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index eef0246..21ea9cd 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -13,13 +13,29 @@ const defaultOption: RequestOptions = { type OptionReturnType = Opt extends { unwrapData: false } ? AxiosResponse : Opt extends { unwrapData: true } ? T : T export type APIClientOptions = { - wrapResponseErrors: boolean + wrapResponseErrors: boolean; + timeout?: number; + retryConfig?: { + maxRetries: number; + baseDelay: number; + }; } export class API { private axios: AxiosInstance - constructor (readonly accessToken: string, public hackmdAPIEndpointURL: string = "https://api.hackmd.io/v1", public options: APIClientOptions = { wrapResponseErrors: true }) { + constructor ( + readonly accessToken: string, + public hackmdAPIEndpointURL: string = "https://api.hackmd.io/v1", + public options: APIClientOptions = { + wrapResponseErrors: true, + timeout: 30000, + retryConfig: { + maxRetries: 3, + baseDelay: 100, + }, + } + ) { if (!accessToken) { throw new HackMDErrors.MissingRequiredArgument('Missing access token when creating HackMD client') } @@ -28,7 +44,8 @@ export class API { baseURL: hackmdAPIEndpointURL, headers:{ "Content-Type": "application/json", - } + }, + timeout: options.timeout }) this.axios.interceptors.request.use( @@ -71,13 +88,51 @@ export class API { `Received an error response (${err.response.status} ${err.response.statusText}) from HackMD`, err.response.status, err.response.statusText, - ) + ); } } - ) + ); + } + if (options.retryConfig) { + this.createRetryInterceptor(this.axios, options.retryConfig.maxRetries, options.retryConfig.baseDelay); } } + private exponentialBackoff(retries: number, baseDelay: number): number { + return Math.pow(2, retries) * baseDelay; + } + + private isRetryableError(error: AxiosError): boolean { + return ( + !error.response || + (error.response.status >= 500 && error.response.status < 600) || + error.response.status === 429 + ); + } + + private createRetryInterceptor(axiosInstance: AxiosInstance, maxRetries: number, baseDelay: number): void { + let retryCount = 0; + + axiosInstance.interceptors.response.use( + response => response, + async error => { + if (retryCount < maxRetries && this.isRetryableError(error)) { + const remainingCredits = parseInt(error.response?.headers['x-ratelimit-userremaining'], 10); + + if (isNaN(remainingCredits) || remainingCredits > 0) { + retryCount++; + const delay = this.exponentialBackoff(retryCount, baseDelay); + console.warn(`Retrying request... attempt #${retryCount} after delay of ${delay}ms`); + await new Promise(resolve => setTimeout(resolve, delay)); + return axiosInstance(error.config); + } + } + + retryCount = 0; // Reset retry count after a successful request or when not retrying + return Promise.reject(error); + } + ); + } async getMe (options = defaultOption as Opt): Promise> { return this.unwrapData(this.axios.get("me"), options.unwrapData) as unknown as OptionReturnType } diff --git a/nodejs/tests/api.spec.ts b/nodejs/tests/api.spec.ts index d56a846..2acaadc 100644 --- a/nodejs/tests/api.spec.ts +++ b/nodejs/tests/api.spec.ts @@ -43,7 +43,7 @@ test('should throw axios error object if set wrapResponseErrors to false', async }) server.use( - rest.get('https://api.hackmd.io/v1/me', (req, res, ctx) => { + rest.get('https://api.hackmd.io/v1/me', (req: any, res: (arg0: any) => any, ctx: { status: (arg0: number) => any }) => { return res(ctx.status(429)) }), )