Skip to content

Commit 0f8c17d

Browse files
Write your own HTTP(S) Load Tester (#45)
* Completed till step 5 * updated global readme
1 parent 8f007d5 commit 0f8c17d

File tree

6 files changed

+333
-0
lines changed

6 files changed

+333
-0
lines changed

README.md

+4
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ Checkout my [Notion](https://mohitjain.notion.site/Coding-Challenges-af9b8197a43
4848
27. [Write Your Own Rate Limiter](src/27/)
4949
28. [Write Your Own NTP Client](src/28/)
5050

51+
Don't ask me about this GAP. This ain't an interview!
52+
53+
41. [Write Your Own HTTP(S) Load Tester](src/41/)
54+
5155
## Installation
5256

5357
The following command will build all the .ts files present in `src` folder into a new `build` folder.

src/41/README.md

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Challenge 41 - Write Your Own HTTP(S) Load Tester
2+
3+
This challenge corresponds to the 41<sup>st</sup> part of the Coding Challenges series by John Crickett https://codingchallenges.fyi/challenges/challenge-load-tester.
4+
5+
## Description
6+
7+
The objective of the challenge is to build a HTTP(s) Load Tester that can query a given address and return some important stats such as Total Request Time, Time to First Byte, Time to Last Byte.
8+
9+
## Usage
10+
11+
You can use the `ts-node` tool to run the command line version of the NTP Client as follows:
12+
13+
```bash
14+
npx ts-node <path/to/index.ts> -u <url> [-n <number-of-requests>] [-c <concurrency>]
15+
```
16+
17+
### Options
18+
19+
- `-u <url>`: The URL on which load testing needs to be performed.
20+
21+
- `-n <number-of-requests>`: The number of requests sent to the server. This are sent in series. Default = 10.
22+
23+
- `-c <concurrency>`: The number of concurrent requests to send. Default = 1.
24+
25+
### Examples
26+
27+
```bash
28+
# Load test https://google.com with 10 requests and 10 concurrency
29+
npx ts-node <path-to-index.ts> -u https://google.com -n 10 -c 10
30+
```
31+
32+
### Description
33+
34+
- [customer_request.ts](custom_request.ts): A helper function which calculates some stats while doing a network GET request. The code is inspired from https://gabrieleromanato.name/nodejs-get-the-time-to-first-byte-ttfb-of-a-website.
35+
36+
- [load_tester.ts](load_tester.ts): The main load tester implementation.
37+
38+
## Run tests
39+
40+
To run the tests for the Load Tester, go to the root directory of this repository and run the following command:
41+
42+
```bash
43+
npm test src/41/
44+
```
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { customRequest } from '../custom_request';
2+
3+
describe('Testing custom request', () => {
4+
it('should handle redirects', async () => {
5+
const url = 'https://google.com';
6+
const stats = await customRequest(url);
7+
expect(stats.statusCode).toBe(200);
8+
});
9+
10+
it('should handle http requests', async () => {
11+
const url = 'http://google.com';
12+
const stats = await customRequest(url);
13+
expect(stats.statusCode).toBe(200);
14+
});
15+
16+
it('should handle https requests', async () => {
17+
const url = 'https://www.google.com';
18+
const stats = await customRequest(url);
19+
expect(stats.statusCode).toBe(200);
20+
});
21+
22+
it('should raise error on invalid URL', (done) => {
23+
const url = 'http://123213213123123.com';
24+
customRequest(url).catch(() => done());
25+
});
26+
27+
it('should raise error on invalid protocol', (done) => {
28+
const url = '123213213123123.com';
29+
customRequest(url).catch(() => done());
30+
});
31+
});

src/41/custom_request.ts

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import https from 'https';
2+
import http from 'http';
3+
4+
export type Stats = {
5+
body: string;
6+
statusCode: number;
7+
trtMs: number;
8+
ttfbMs: number;
9+
ttlbMs: number;
10+
};
11+
12+
export function customRequest(url: string): Promise<Stats> {
13+
const protocol = url.startsWith('https') ? https : http;
14+
15+
const stats: Stats = {
16+
body: '',
17+
statusCode: 0,
18+
trtMs: 0,
19+
ttfbMs: 0,
20+
ttlbMs: 0
21+
};
22+
23+
const processTimes = {
24+
dnsLookup: BigInt(0),
25+
tcpConnection: BigInt(0),
26+
tlsHandshake: BigInt(0),
27+
responseBodyStart: BigInt(0),
28+
responseBodyEnd: BigInt(0)
29+
};
30+
31+
// Ensure connections are not reused
32+
const agent = new protocol.Agent({
33+
keepAlive: false
34+
});
35+
36+
return new Promise<Stats>((resolve, reject) => {
37+
// Close the connection after the request
38+
const options = {
39+
agent,
40+
headers: {
41+
Connection: 'close'
42+
}
43+
};
44+
45+
const req = protocol.get(url, options, (res) => {
46+
stats.statusCode = res.statusCode ?? -1;
47+
48+
// Handle redirects
49+
if (res.statusCode === 301 && res.headers.location) {
50+
const redirectUrl = res.headers.location;
51+
req.destroy();
52+
53+
customRequest(redirectUrl)
54+
.then((redirectStats) => resolve(redirectStats))
55+
.catch((err) => reject(err));
56+
return;
57+
}
58+
59+
res.once('data', () => {
60+
processTimes.responseBodyStart = process.hrtime.bigint();
61+
stats.ttfbMs =
62+
Number(processTimes.responseBodyStart - processTimes.tlsHandshake) /
63+
1000_000;
64+
});
65+
66+
res.on('data', (d) => {
67+
stats.body += d;
68+
});
69+
70+
res.on('end', () => {
71+
processTimes.responseBodyEnd = process.hrtime.bigint();
72+
stats.ttlbMs =
73+
Number(processTimes.responseBodyEnd - processTimes.tlsHandshake) /
74+
1000_000;
75+
stats.trtMs =
76+
Number(processTimes.responseBodyEnd - processTimes.dnsLookup) /
77+
1000_000;
78+
resolve(stats);
79+
});
80+
});
81+
82+
req.on('error', (error) => {
83+
reject(error);
84+
});
85+
86+
req.on('socket', (socket) => {
87+
socket.on('lookup', () => {
88+
processTimes.dnsLookup = process.hrtime.bigint();
89+
});
90+
socket.on('connect', () => {
91+
processTimes.tcpConnection = process.hrtime.bigint();
92+
});
93+
94+
socket.on('secureConnect', () => {
95+
processTimes.tlsHandshake = process.hrtime.bigint();
96+
});
97+
});
98+
99+
req.end();
100+
});
101+
}

src/41/index.ts

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { program } from 'commander';
2+
import { loadTester } from './load_tester';
3+
4+
program.option('-u <url>', 'URL to load test on');
5+
program.option('-n <num>', 'Number of requests to make', '10');
6+
program.option(
7+
'-c <concurrency>',
8+
'Number of concurrent requests to make',
9+
'1'
10+
);
11+
12+
program.parse();
13+
14+
const { u, n, c } = program.opts();
15+
16+
try {
17+
new URL(u);
18+
} catch (e) {
19+
console.error((e as Error).message);
20+
process.exit(1);
21+
}
22+
23+
const numberOfRequests = parseInt(n);
24+
const concurrency = parseInt(c);
25+
const url = u.toString();
26+
27+
async function main() {
28+
const result = await loadTester({ numberOfRequests, concurrency, url });
29+
console.log(result);
30+
}
31+
32+
main();

src/41/load_tester.ts

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { Stats, customRequest } from './custom_request';
2+
3+
type LoadTesterParams = {
4+
numberOfRequests: number;
5+
concurrency: number;
6+
url: string;
7+
};
8+
9+
type TMinMaxMean = {
10+
min: number;
11+
max: number;
12+
mean: number;
13+
};
14+
15+
type TLoadTesterReturn = {
16+
totalRequests: number;
17+
successes: number;
18+
failures: number;
19+
totalTimeRequestMs: TMinMaxMean;
20+
ttfbMs: TMinMaxMean;
21+
ttlbMs: TMinMaxMean;
22+
reqPerSec: number;
23+
};
24+
25+
async function makeRequest(
26+
url: string,
27+
numberOfRequests: number = 10
28+
): Promise<Stats[]> {
29+
const stats: Stats[] = [];
30+
for (let i = 0; i < numberOfRequests; i++) {
31+
try {
32+
stats.push(await customRequest(url));
33+
} catch (_) {
34+
stats.push({
35+
body: '',
36+
statusCode: 500,
37+
trtMs: -1,
38+
ttfbMs: -1,
39+
ttlbMs: -1
40+
});
41+
}
42+
}
43+
return stats;
44+
}
45+
46+
export async function loadTester({
47+
numberOfRequests,
48+
concurrency,
49+
url
50+
}: LoadTesterParams): Promise<TLoadTesterReturn> {
51+
const startTime = process.hrtime.bigint();
52+
53+
const promises = [];
54+
for (let i = 0; i < concurrency; i++) {
55+
promises.push(makeRequest(url, numberOfRequests));
56+
}
57+
const statLists = await Promise.all(promises);
58+
59+
const endTime = process.hrtime.bigint();
60+
61+
let successes = 0,
62+
failures = 0;
63+
64+
// Max, Min and Mean for [ttfb, ttlb, trt]
65+
const max = [-1, -1, -1],
66+
min = [Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE],
67+
mean = [0, 0, 0];
68+
69+
statLists.forEach((stats) => {
70+
stats.forEach((stat) => {
71+
if (stat.statusCode >= 200 && stat.statusCode <= 299) {
72+
successes++;
73+
74+
// ttfbMs
75+
if (stat.ttfbMs > max[0]) {
76+
max[0] = stat.ttfbMs;
77+
}
78+
if (stat.ttfbMs < min[0]) {
79+
min[0] = stat.ttfbMs;
80+
}
81+
mean[0] += stat.ttfbMs;
82+
83+
// ttlbMs
84+
if (stat.ttlbMs > max[1]) {
85+
max[1] = stat.ttlbMs;
86+
}
87+
if (stat.ttlbMs < min[1]) {
88+
min[1] = stat.ttlbMs;
89+
}
90+
mean[1] += stat.ttlbMs;
91+
92+
// trtMs
93+
if (stat.trtMs > max[2]) {
94+
max[2] = stat.trtMs;
95+
}
96+
if (stat.trtMs < min[2]) {
97+
min[2] = stat.trtMs;
98+
}
99+
mean[2] += stat.trtMs;
100+
} else {
101+
failures++;
102+
}
103+
});
104+
});
105+
106+
mean[0] = Number((mean[0] / successes).toFixed(4));
107+
mean[1] = Number((mean[1] / successes).toFixed(4));
108+
mean[2] = Number((mean[2] / successes).toFixed(4));
109+
110+
return {
111+
totalRequests: numberOfRequests * concurrency,
112+
successes,
113+
failures,
114+
ttfbMs: { min: min[0], max: max[0], mean: mean[0] },
115+
ttlbMs: { min: min[1], max: max[1], mean: mean[1] },
116+
totalTimeRequestMs: { min: min[2], max: max[2], mean: mean[2] },
117+
reqPerSec: Number(
118+
(successes / (Number(endTime - startTime) / 1000_000_000)).toFixed(2)
119+
)
120+
};
121+
}

0 commit comments

Comments
 (0)