Skip to content

Commit 4afba97

Browse files
authored
Merge pull request #52 from warrant-dev/feat/AddRequestRetries
Add request retries for network/connection and 502 errors
2 parents 982b5a5 + c86bdb8 commit 4afba97

File tree

4 files changed

+209
-32
lines changed

4 files changed

+209
-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: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ 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+
const RETRY_STATUS_CODES = [500, 502, 504];
41+
42+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
43+
3744
export default class ApiClient implements HttpClient {
3845
private config: HttpClientConfig;
3946

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

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

5356
return this.parseResponse(response);
5457
}
5558

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

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

6564
return this.parseResponse(response);
6665
}
6766

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

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

7772
return this.parseResponse(response);
7873
}
7974

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

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

8980
return this.parseResponse(response);
9081
}
9182

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