Skip to content

Commit e5cbd63

Browse files
authored
Merge pull request #30 from cryptlex/feat-retries
refactor(http): abstract to HttpClient
2 parents 5dfe722 + 752f2a1 commit e5cbd63

File tree

2 files changed

+226
-22
lines changed

2 files changed

+226
-22
lines changed

src/client.ts

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import axios, { AxiosInstance } from "axios";
1+
import { AxiosInstance } from "axios";
22
import CryptlexWebApiClientOptions from "./client-options.js";
3+
import { HttpClient } from "./http-client.js";
34

45
import { ApiResponse } from "./services/api.types.js";
56
import { LicenseService } from "./services/license.service.js";
@@ -26,16 +27,10 @@ export default class CryptlexWebApiClient {
2627
/**
2728
* HttpClient to communicate with the Cryptlex Web API
2829
*/
29-
httpClient: AxiosInstance;
30+
httpClientInstance: AxiosInstance;
3031

3132
constructor(options: CryptlexWebApiClientOptions) {
32-
this.httpClient = axios.create({
33-
baseURL: options.baseUrl,
34-
timeout: options.timeout,
35-
headers: {
36-
Authorization: `Bearer ${options.accessToken}`,
37-
},
38-
});
33+
this.httpClientInstance = new HttpClient(options).instance;
3934
}
4035

4136
/**
@@ -46,7 +41,24 @@ export default class CryptlexWebApiClient {
4641
createLicense(
4742
license: LicenseCreateRequest
4843
): Promise<ApiResponse<LicenseResponse>> {
49-
return LicenseService.createLicense(this.httpClient, license);
44+
return LicenseService.createLicense(this.httpClientInstance, license);
45+
}
46+
47+
/**
48+
* Create multiple licenses
49+
* @param {LicenseCreateRequest} license License object to create
50+
* @param {number} count Number of licenses to create
51+
* @returns {Promise<ApiResponse<LicenseResponse>[]>} Promise that resolves to an array of Web API responses.
52+
*/
53+
createLicenses(
54+
license: LicenseCreateRequest,
55+
count: number
56+
): Promise<ApiResponse<LicenseResponse>[]> {
57+
return Promise.all(
58+
Array.from({ length: count }, () => {
59+
return LicenseService.createLicense(this.httpClientInstance, license);
60+
})
61+
);
5062
}
5163

5264
/**
@@ -60,7 +72,7 @@ export default class CryptlexWebApiClient {
6072
id: string,
6173
license: LicenseUpdateRequest
6274
): Promise<ApiResponse<LicenseResponse>> {
63-
return LicenseService.updateLicense(this.httpClient, id, license);
75+
return LicenseService.updateLicense(this.httpClientInstance, id, license);
6476
}
6577

6678
/**
@@ -69,7 +81,7 @@ export default class CryptlexWebApiClient {
6981
* @returns {Promise<ApiResponse<LicenseResponse>>} Promise that resolves to the Web API response
7082
*/
7183
deleteLicense(id: string): Promise<ApiResponse<any>> {
72-
return LicenseService.deleteLicense(this.httpClient, id);
84+
return LicenseService.deleteLicense(this.httpClientInstance, id);
7385
}
7486

7587
/**
@@ -84,7 +96,12 @@ export default class CryptlexWebApiClient {
8496
limit: number,
8597
parameters?: LicenseListQueryParameters
8698
) {
87-
return LicenseService.getLicenses(this.httpClient, page, limit, parameters);
99+
return LicenseService.getLicenses(
100+
this.httpClientInstance,
101+
page,
102+
limit,
103+
parameters
104+
);
88105
}
89106

90107
/**
@@ -93,7 +110,7 @@ export default class CryptlexWebApiClient {
93110
* @returns {Promise<ApiResponse<LicenseResponse>>} Promise that resolves to the Web API response
94111
*/
95112
getLicense(id: string): Promise<ApiResponse<LicenseResponse>> {
96-
return LicenseService.getLicense(this.httpClient, id);
113+
return LicenseService.getLicense(this.httpClientInstance, id);
97114
}
98115

99116
/**
@@ -102,7 +119,7 @@ export default class CryptlexWebApiClient {
102119
* @returns {Promise<ApiResponse<LicenseResponse>>} Promise that resolves to the Web API response
103120
*/
104121
renewLicense(id: string): Promise<ApiResponse<LicenseResponse>> {
105-
return LicenseService.renewLicense(this.httpClient, id);
122+
return LicenseService.renewLicense(this.httpClientInstance, id);
106123
}
107124

108125
/**
@@ -115,7 +132,11 @@ export default class CryptlexWebApiClient {
115132
id: string,
116133
extensionLength: number
117134
): Promise<ApiResponse<LicenseResponse>> {
118-
return LicenseService.extendLicense(this.httpClient, id, extensionLength);
135+
return LicenseService.extendLicense(
136+
this.httpClientInstance,
137+
id,
138+
extensionLength
139+
);
119140
}
120141

121142
/**
@@ -124,7 +145,7 @@ export default class CryptlexWebApiClient {
124145
* @returns {Promise<ApiResponse<UserResponse>>} Promise that resolves to the Web API response
125146
*/
126147
createUser(user: UserCreateRequest): Promise<ApiResponse<UserResponse>> {
127-
return UserService.createUser(this.httpClient, user);
148+
return UserService.createUser(this.httpClientInstance, user);
128149
}
129150

130151
/**
@@ -133,7 +154,7 @@ export default class CryptlexWebApiClient {
133154
* @returns {Promise<ApiResponse<any>>} Promise that resolves to the Web API response
134155
*/
135156
deleteUser(id: string): Promise<ApiResponse<any>> {
136-
return UserService.deleteUser(this.httpClient, id);
157+
return UserService.deleteUser(this.httpClientInstance, id);
137158
}
138159

139160
/**
@@ -142,7 +163,7 @@ export default class CryptlexWebApiClient {
142163
* @returns {Promise<ApiResponse<UserResponse>>} Promise that resolves to the Web API response
143164
*/
144165
getUser(id: string): Promise<ApiResponse<UserResponse>> {
145-
return UserService.getUser(this.httpClient, id);
166+
return UserService.getUser(this.httpClientInstance, id);
146167
}
147168

148169
/**
@@ -157,7 +178,12 @@ export default class CryptlexWebApiClient {
157178
limit: number,
158179
parameters?: UserListQueryParameters
159180
): Promise<ApiResponse<UserResponse[]>> {
160-
return UserService.getUsers(this.httpClient, page, limit, parameters);
181+
return UserService.getUsers(
182+
this.httpClientInstance,
183+
page,
184+
limit,
185+
parameters
186+
);
161187
}
162188

163189
/**
@@ -167,7 +193,7 @@ export default class CryptlexWebApiClient {
167193
* @returns {Promise<ApiResponse<UserResponse>>} Promise that resolves to the Web API response
168194
*/
169195
updateUser(id: string, user: UserUpdateRequest) {
170-
return UserService.updateUser(this.httpClient, id, user);
196+
return UserService.updateUser(this.httpClientInstance, id, user);
171197
}
172198

173199
/**
@@ -178,6 +204,6 @@ export default class CryptlexWebApiClient {
178204
* @returns {Promise<ApiResponse<any>>} Promise that resolves to the Web API response
179205
*/
180206
generateResetPasswordToken(id: string) {
181-
return UserService.generateResetPasswordToken(this.httpClient, id);
207+
return UserService.generateResetPasswordToken(this.httpClientInstance, id);
182208
}
183209
}

src/http-client.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios";
2+
import CryptlexWebApiClientOptions from "./client-options.js";
3+
4+
/** An extension of AxiosRequestConfig to store */
5+
type AxiosRetryConfig = AxiosRequestConfig<any> & {
6+
/** Stores the number of retries made for a request */
7+
retries?: number;
8+
9+
/** Stores the time when the request was made. UTC timestamp. */
10+
lastRequestTime?: number;
11+
};
12+
13+
/** AxiosError with AxiosRetryConfig */
14+
type AxiosErrorWithRetries = AxiosError & {
15+
config: AxiosRetryConfig;
16+
};
17+
18+
/** Type alias for units. */
19+
type Milliseconds = number;
20+
21+
/**
22+
* Default time to stall program
23+
*/
24+
const DEFAULT_SLEEP_TIME: Milliseconds = 30000;
25+
26+
/**
27+
* Maximum allowed retries.
28+
*/
29+
const MAX_RETRIES = 3;
30+
31+
/**
32+
* The HttpClient class abstracts any customizations on the underlying client(Axios) from the CryptlexWebApiClient class.
33+
*/
34+
export class HttpClient {
35+
/** Axios instance */
36+
instance: AxiosInstance;
37+
38+
/**
39+
* @private
40+
* @internal
41+
* @param {CryptlexWebApiClientOptions} options CryptlexWebApiClientOptions
42+
*/
43+
constructor(options: CryptlexWebApiClientOptions) {
44+
this.instance = axios.create({
45+
baseURL: options.baseUrl,
46+
timeout: options.timeout,
47+
headers: {
48+
Authorization: `Bearer ${options.accessToken}`,
49+
},
50+
});
51+
52+
this.setupInterceptors();
53+
}
54+
55+
/**
56+
* @private
57+
* @internal
58+
* Initializes interceptors for Axios client
59+
* @returns {void}
60+
*/
61+
private setupInterceptors() {
62+
// Setup the request interceptor to maintain retry state.
63+
this.instance.interceptors.request.use((config: AxiosRetryConfig) => {
64+
// Determine the number of retries attempted
65+
const retries = config.retries || 0;
66+
// Initialize config.retries for every request to 0, or previous value to maintain state.
67+
config.retries = retries;
68+
// Initialize config.lastRetryTime for every request to allow debouncing.
69+
config.lastRequestTime = Date.now();
70+
// Return mutated config
71+
return config;
72+
});
73+
74+
// Setup the response interceptor to handle retrying of 429, and 5xx errors.
75+
this.instance.interceptors.response.use(
76+
// For valid response of range 2xx, do nothing.
77+
undefined,
78+
// For responses beyond 2xx.
79+
async (error: AxiosErrorWithRetries) => {
80+
if (
81+
// Error exists
82+
error &&
83+
// Error is AxiosError
84+
error.response &&
85+
// AxiosError has all the request parameters of the failed request
86+
error.config
87+
) {
88+
if (
89+
// Response status is 429
90+
error.response.status === 429 &&
91+
// Response headers exist
92+
error.response.headers &&
93+
// Response header 'retry-after' is less than or equal to 60 seconds
94+
Number(error.response.headers["retry-after"]) <= 60
95+
) {
96+
// Wait for ('retry-after' + 2) seconds.
97+
await sleep(
98+
Number(error.response.headers["retry-after"]) * 1000 + 2000
99+
);
100+
101+
// Retry request
102+
return handleRetry(error);
103+
}
104+
// handle 5xx
105+
else if (
106+
error.response.status >= 500 &&
107+
error.response.status <= 599
108+
) {
109+
// Wait for the default sleep time.
110+
await sleep();
111+
112+
// Retry request
113+
return handleRetry(error);
114+
}
115+
// Other errors
116+
else {
117+
// Thrown error propagates.
118+
return Promise.reject(error);
119+
}
120+
} else {
121+
// If none of the conditions match, let the error thrown propogate.
122+
return Promise.reject(error);
123+
}
124+
}
125+
);
126+
127+
/**
128+
* Generic handler for retries. Increments the number for retries and returns the initial request.
129+
* @param {AxiosErrorWithRetries} axiosError AxiosError with AxiosRetryConfig
130+
* @returns {Promise} Promise with retried request OR rejection.
131+
*/
132+
const handleRetry = (axiosError: AxiosErrorWithRetries) => {
133+
if (axiosError.config && axiosError.config.headers) {
134+
// Safely get retries (even if undefined)
135+
const retries = axiosError.config.retries || 0;
136+
// Set retries in the AxiosRetryConfig
137+
axiosError.config.retries = retries + 1;
138+
139+
if (axiosError.config.retries < MAX_RETRIES) {
140+
/*
141+
* Workaround for https://github.com/axios/axios/issues/5143
142+
* Some computations that axios.request() makes on the object mangles
143+
* the headers and leads to a 401 if we pass the object as is.
144+
*
145+
* Hence we delete the httpAgent, httpsAgent, and pass the headers after destructuring.
146+
*/
147+
delete axiosError.config.httpAgent;
148+
delete axiosError.config.httpsAgent;
149+
return this.instance.request({
150+
...axiosError.config,
151+
headers: { ...axiosError.config.headers },
152+
});
153+
} else {
154+
return Promise.reject(axiosError);
155+
}
156+
} else {
157+
/**
158+
* Error propogates if config is not correct
159+
*/
160+
return Promise.reject(axiosError);
161+
}
162+
};
163+
}
164+
}
165+
166+
/**
167+
* @internal
168+
* @private
169+
*
170+
* Stalls the program.
171+
* @param {number} ms Time to stall in milliseconds
172+
* @returns {Promise<void>} Promise that stalls program as per the parameter passed when awaited.
173+
*/
174+
function sleep(ms: Milliseconds = DEFAULT_SLEEP_TIME): Promise<void> {
175+
return new Promise((resolve) => {
176+
return setTimeout(resolve, ms);
177+
});
178+
}

0 commit comments

Comments
 (0)