Skip to content

Commit 1f62681

Browse files
committed
Add request retries for network/connection and 502 errors
1 parent 982b5a5 commit 1f62681

File tree

4 files changed

+208
-32
lines changed

4 files changed

+208
-32
lines changed

package-lock.json

Lines changed: 65 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,11 @@
3737
"devDependencies": {
3838
"@types/chai": "^4.3.9",
3939
"@types/mocha": "^10.0.3",
40-
"@types/node": "^18.11.18",
40+
"@types/node": "^18.13.0",
4141
"chai": "^4.3.10",
4242
"mocha": "^10.2.0",
4343
"ts-node": "^10.9.1",
44-
"typescript": "^4.3.2"
44+
"typescript": "^4.3.2",
45+
"undici": "^6.0.1"
4546
}
4647
}

src/HttpClient.ts

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ interface FetchRequestOptions {
3434
body?: string;
3535
}
3636

37+
const MAX_RETRY_ATTEMPTS = 3;
38+
const BACKOFF_MULTIPLIER = 1.5;
39+
const MINIMUM_SLEEP_TIME = 500;
40+
41+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
42+
3743
export default class ApiClient implements HttpClient {
3844
private config: HttpClientConfig;
3945

@@ -44,51 +50,85 @@ export default class ApiClient implements HttpClient {
4450
public async get(requestOptions: HttpClientRequestOptions): Promise<any> {
4551
const [requestUrl, fetchRequestOptions] = this.buildRequestUrlAndOptions("GET", requestOptions);
4652

47-
/* @ts-ignore */
48-
const response = await fetch(requestUrl, fetchRequestOptions);
49-
if (!response.ok) {
50-
throw this.buildError(await response.json());
51-
}
53+
const response = await this.fetchWithRetry(requestUrl, fetchRequestOptions);
5254

5355
return this.parseResponse(response);
5456
}
5557

5658
public async delete(requestOptions: HttpClientRequestOptions): Promise<any> {
5759
const [requestUrl, fetchRequestOptions] = this.buildRequestUrlAndOptions("DELETE", requestOptions);
5860

59-
/* @ts-ignore */
60-
const response = await fetch(requestUrl, fetchRequestOptions);
61-
if (!response.ok) {
62-
throw this.buildError(await response.json());
63-
}
61+
const response = await this.fetchWithRetry(requestUrl, fetchRequestOptions);
6462

6563
return this.parseResponse(response);
6664
}
6765

6866
public async post(requestOptions: HttpClientRequestOptions): Promise<any> {
6967
const [requestUrl, fetchRequestOptions] = this.buildRequestUrlAndOptions("POST", requestOptions);
7068

71-
/* @ts-ignore */
72-
const response = await fetch(requestUrl, fetchRequestOptions);
73-
if (!response.ok) {
74-
throw this.buildError(await response.json());
75-
}
69+
const response = await this.fetchWithRetry(requestUrl, fetchRequestOptions);
7670

7771
return this.parseResponse(response);
7872
}
7973

8074
public async put(requestOptions: HttpClientRequestOptions): Promise<any> {
8175
const [requestUrl, fetchRequestOptions] = this.buildRequestUrlAndOptions("PUT", requestOptions);
8276

83-
/* @ts-ignore */
84-
const response = await fetch(requestUrl, fetchRequestOptions);
85-
if (!response.ok) {
86-
throw this.buildError(await response.json());
87-
}
77+
const response = await this.fetchWithRetry(requestUrl, fetchRequestOptions);
8878

8979
return this.parseResponse(response);
9080
}
9181

82+
private async fetchWithRetry(requestUrl: string, fetchRequestOptions: FetchRequestOptions): Promise<any> {
83+
let response: any = null;
84+
let requestError: any = null;
85+
let retryAttempts = 1;
86+
87+
const makeRequest = async (): Promise<any> => {
88+
try {
89+
response = await fetch(requestUrl, fetchRequestOptions);
90+
} catch (e) {
91+
requestError = e;
92+
}
93+
94+
if (this.shouldRetryRequest(response, requestError, retryAttempts)) {
95+
retryAttempts++;
96+
await sleep(this.getSleepTime(retryAttempts));
97+
return makeRequest();
98+
}
99+
100+
if (!response.ok) {
101+
throw this.buildError(await response.json());
102+
}
103+
104+
return response;
105+
}
106+
107+
return makeRequest();
108+
}
109+
110+
private shouldRetryRequest(response: any, requestError: any, retryAttempt: number): boolean {
111+
if (retryAttempt > MAX_RETRY_ATTEMPTS) {
112+
return false;
113+
}
114+
115+
if (requestError != null && requestError instanceof TypeError) {
116+
return true;
117+
}
118+
119+
if (response?.status == 502) {
120+
return true;
121+
}
122+
123+
return false;
124+
}
125+
126+
private getSleepTime(retryAttempt: number): number {
127+
let sleepTime = MINIMUM_SLEEP_TIME * Math.pow(BACKOFF_MULTIPLIER, retryAttempt);
128+
const jitter = Math.random() + 0.5;
129+
return sleepTime * jitter;
130+
}
131+
92132
private buildRequestUrlAndOptions(method: FetchRequestOptions["method"], requestOptions?: HttpClientRequestOptions): [string, FetchRequestOptions] {
93133
let baseUrl = this.config.baseUrl;
94134
const fetchRequestOptions: FetchRequestOptions = {

test/WarrantClientTest.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import WarrantClient from "../src/WarrantClient";
2+
import { ApiError } from "../src/types";
3+
import { assert } from "chai";
4+
import { MockAgent, setGlobalDispatcher } from "undici";
5+
6+
describe('WarrantClientTest', function () {
7+
before(function () {
8+
this.warrant = new WarrantClient({ apiKey: "my_api_key", endpoint: "http://localhost:8000" });
9+
10+
const agent = new MockAgent();
11+
agent.disableNetConnect();
12+
this.client = agent.get("http://localhost:8000")
13+
setGlobalDispatcher(agent);
14+
});
15+
16+
it('should make request after retries', async function () {
17+
this.timeout(10000);
18+
this.client
19+
.intercept({
20+
path: "/v2/objects/user/some-user",
21+
method: "GET"
22+
})
23+
.reply(502);
24+
25+
this.client
26+
.intercept({
27+
path: "/v2/objects/user/some-user",
28+
method: "GET"
29+
})
30+
.reply(502);
31+
32+
this.client
33+
.intercept({
34+
path: "/v2/objects/user/some-user",
35+
method: "GET"
36+
})
37+
.reply(200, { "objectType": "user", "objectId": "some-user" });
38+
39+
const fetchedUser = await this.warrant.User.get("some-user");
40+
41+
assert.strictEqual(fetchedUser.userId, "some-user");
42+
assert.strictEqual(fetchedUser.meta, undefined);
43+
});
44+
45+
it('should stop requests after max retries', async function () {
46+
this.timeout(10000);
47+
this.client
48+
.intercept({
49+
path: "/v2/objects/user/some-user",
50+
method: "GET"
51+
})
52+
.reply(502, {
53+
message: "Bad Gateway"
54+
});
55+
56+
this.client
57+
.intercept({
58+
path: "/v2/objects/user/some-user",
59+
method: "GET"
60+
})
61+
.reply(502, {
62+
message: "Bad Gateway"
63+
});
64+
65+
this.client
66+
.intercept({
67+
path: "/v2/objects/user/some-user",
68+
method: "GET"
69+
})
70+
.reply(502, {
71+
message: "Bad Gateway"
72+
});
73+
74+
try {
75+
await this.warrant.User.get("some-user");
76+
} catch (e) {
77+
assert.instanceOf(e, ApiError);
78+
}
79+
});
80+
});

0 commit comments

Comments
 (0)