Skip to content

Commit

Permalink
Merge pull request #30 from Taombawkry/develop
Browse files Browse the repository at this point in the history
Add retry logic with exponential backoff to API client
  • Loading branch information
Yukaii authored Oct 11, 2024
2 parents 0c6f090 + 93a0060 commit 737366b
Show file tree
Hide file tree
Showing 2 changed files with 61 additions and 6 deletions.
65 changes: 60 additions & 5 deletions nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,29 @@ const defaultOption: RequestOptions = {
type OptionReturnType<Opt, T> = Opt extends { unwrapData: false } ? AxiosResponse<T> : 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')
}
Expand All @@ -28,7 +44,8 @@ export class API {
baseURL: hackmdAPIEndpointURL,
headers:{
"Content-Type": "application/json",
}
},
timeout: options.timeout
})

this.axios.interceptors.request.use(
Expand Down Expand Up @@ -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<Opt extends RequestOptions> (options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetMe>> {
return this.unwrapData(this.axios.get<GetMe>("me"), options.unwrapData) as unknown as OptionReturnType<Opt, GetMe>
}
Expand Down
2 changes: 1 addition & 1 deletion nodejs/tests/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}),
)
Expand Down

0 comments on commit 737366b

Please sign in to comment.