Skip to content

Commit

Permalink
Write your own HTTP(S) Load Tester (#45)
Browse files Browse the repository at this point in the history
* Completed till step 5

* updated global readme
  • Loading branch information
jainmohit2001 authored Dec 23, 2023
1 parent 8f007d5 commit 0f8c17d
Show file tree
Hide file tree
Showing 6 changed files with 333 additions and 0 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ Checkout my [Notion](https://mohitjain.notion.site/Coding-Challenges-af9b8197a43
27. [Write Your Own Rate Limiter](src/27/)
28. [Write Your Own NTP Client](src/28/)

Don't ask me about this GAP. This ain't an interview!

41. [Write Your Own HTTP(S) Load Tester](src/41/)

## Installation

The following command will build all the .ts files present in `src` folder into a new `build` folder.
Expand Down
44 changes: 44 additions & 0 deletions src/41/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Challenge 41 - Write Your Own HTTP(S) Load Tester

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.

## Description

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.

## Usage

You can use the `ts-node` tool to run the command line version of the NTP Client as follows:

```bash
npx ts-node <path/to/index.ts> -u <url> [-n <number-of-requests>] [-c <concurrency>]
```

### Options

- `-u <url>`: The URL on which load testing needs to be performed.

- `-n <number-of-requests>`: The number of requests sent to the server. This are sent in series. Default = 10.

- `-c <concurrency>`: The number of concurrent requests to send. Default = 1.

### Examples

```bash
# Load test https://google.com with 10 requests and 10 concurrency
npx ts-node <path-to-index.ts> -u https://google.com -n 10 -c 10
```

### Description

- [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.

- [load_tester.ts](load_tester.ts): The main load tester implementation.

## Run tests

To run the tests for the Load Tester, go to the root directory of this repository and run the following command:

```bash
npm test src/41/
```
31 changes: 31 additions & 0 deletions src/41/__tests__/custom_request.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { customRequest } from '../custom_request';

describe('Testing custom request', () => {
it('should handle redirects', async () => {
const url = 'https://google.com';
const stats = await customRequest(url);
expect(stats.statusCode).toBe(200);
});

it('should handle http requests', async () => {
const url = 'http://google.com';
const stats = await customRequest(url);
expect(stats.statusCode).toBe(200);
});

it('should handle https requests', async () => {
const url = 'https://www.google.com';
const stats = await customRequest(url);
expect(stats.statusCode).toBe(200);
});

it('should raise error on invalid URL', (done) => {
const url = 'http://123213213123123.com';
customRequest(url).catch(() => done());
});

it('should raise error on invalid protocol', (done) => {
const url = '123213213123123.com';
customRequest(url).catch(() => done());
});
});
101 changes: 101 additions & 0 deletions src/41/custom_request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import https from 'https';
import http from 'http';

export type Stats = {
body: string;
statusCode: number;
trtMs: number;
ttfbMs: number;
ttlbMs: number;
};

export function customRequest(url: string): Promise<Stats> {
const protocol = url.startsWith('https') ? https : http;

const stats: Stats = {
body: '',
statusCode: 0,
trtMs: 0,
ttfbMs: 0,
ttlbMs: 0
};

const processTimes = {
dnsLookup: BigInt(0),
tcpConnection: BigInt(0),
tlsHandshake: BigInt(0),
responseBodyStart: BigInt(0),
responseBodyEnd: BigInt(0)
};

// Ensure connections are not reused
const agent = new protocol.Agent({
keepAlive: false
});

return new Promise<Stats>((resolve, reject) => {
// Close the connection after the request
const options = {
agent,
headers: {
Connection: 'close'
}
};

const req = protocol.get(url, options, (res) => {
stats.statusCode = res.statusCode ?? -1;

// Handle redirects
if (res.statusCode === 301 && res.headers.location) {
const redirectUrl = res.headers.location;
req.destroy();

customRequest(redirectUrl)
.then((redirectStats) => resolve(redirectStats))
.catch((err) => reject(err));
return;
}

res.once('data', () => {
processTimes.responseBodyStart = process.hrtime.bigint();
stats.ttfbMs =
Number(processTimes.responseBodyStart - processTimes.tlsHandshake) /
1000_000;
});

res.on('data', (d) => {
stats.body += d;
});

res.on('end', () => {
processTimes.responseBodyEnd = process.hrtime.bigint();
stats.ttlbMs =
Number(processTimes.responseBodyEnd - processTimes.tlsHandshake) /
1000_000;
stats.trtMs =
Number(processTimes.responseBodyEnd - processTimes.dnsLookup) /
1000_000;
resolve(stats);
});
});

req.on('error', (error) => {
reject(error);
});

req.on('socket', (socket) => {
socket.on('lookup', () => {
processTimes.dnsLookup = process.hrtime.bigint();
});
socket.on('connect', () => {
processTimes.tcpConnection = process.hrtime.bigint();
});

socket.on('secureConnect', () => {
processTimes.tlsHandshake = process.hrtime.bigint();
});
});

req.end();
});
}
32 changes: 32 additions & 0 deletions src/41/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { program } from 'commander';
import { loadTester } from './load_tester';

program.option('-u <url>', 'URL to load test on');
program.option('-n <num>', 'Number of requests to make', '10');
program.option(
'-c <concurrency>',
'Number of concurrent requests to make',
'1'
);

program.parse();

const { u, n, c } = program.opts();

try {
new URL(u);
} catch (e) {
console.error((e as Error).message);
process.exit(1);
}

const numberOfRequests = parseInt(n);
const concurrency = parseInt(c);
const url = u.toString();

async function main() {
const result = await loadTester({ numberOfRequests, concurrency, url });
console.log(result);
}

main();
121 changes: 121 additions & 0 deletions src/41/load_tester.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Stats, customRequest } from './custom_request';

type LoadTesterParams = {
numberOfRequests: number;
concurrency: number;
url: string;
};

type TMinMaxMean = {
min: number;
max: number;
mean: number;
};

type TLoadTesterReturn = {
totalRequests: number;
successes: number;
failures: number;
totalTimeRequestMs: TMinMaxMean;
ttfbMs: TMinMaxMean;
ttlbMs: TMinMaxMean;
reqPerSec: number;
};

async function makeRequest(
url: string,
numberOfRequests: number = 10
): Promise<Stats[]> {
const stats: Stats[] = [];
for (let i = 0; i < numberOfRequests; i++) {
try {
stats.push(await customRequest(url));
} catch (_) {
stats.push({
body: '',
statusCode: 500,
trtMs: -1,
ttfbMs: -1,
ttlbMs: -1
});
}
}
return stats;
}

export async function loadTester({
numberOfRequests,
concurrency,
url
}: LoadTesterParams): Promise<TLoadTesterReturn> {
const startTime = process.hrtime.bigint();

const promises = [];
for (let i = 0; i < concurrency; i++) {
promises.push(makeRequest(url, numberOfRequests));
}
const statLists = await Promise.all(promises);

const endTime = process.hrtime.bigint();

let successes = 0,
failures = 0;

// Max, Min and Mean for [ttfb, ttlb, trt]
const max = [-1, -1, -1],
min = [Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE],
mean = [0, 0, 0];

statLists.forEach((stats) => {
stats.forEach((stat) => {
if (stat.statusCode >= 200 && stat.statusCode <= 299) {
successes++;

// ttfbMs
if (stat.ttfbMs > max[0]) {
max[0] = stat.ttfbMs;
}
if (stat.ttfbMs < min[0]) {
min[0] = stat.ttfbMs;
}
mean[0] += stat.ttfbMs;

// ttlbMs
if (stat.ttlbMs > max[1]) {
max[1] = stat.ttlbMs;
}
if (stat.ttlbMs < min[1]) {
min[1] = stat.ttlbMs;
}
mean[1] += stat.ttlbMs;

// trtMs
if (stat.trtMs > max[2]) {
max[2] = stat.trtMs;
}
if (stat.trtMs < min[2]) {
min[2] = stat.trtMs;
}
mean[2] += stat.trtMs;
} else {
failures++;
}
});
});

mean[0] = Number((mean[0] / successes).toFixed(4));
mean[1] = Number((mean[1] / successes).toFixed(4));
mean[2] = Number((mean[2] / successes).toFixed(4));

return {
totalRequests: numberOfRequests * concurrency,
successes,
failures,
ttfbMs: { min: min[0], max: max[0], mean: mean[0] },
ttlbMs: { min: min[1], max: max[1], mean: mean[1] },
totalTimeRequestMs: { min: min[2], max: max[2], mean: mean[2] },
reqPerSec: Number(
(successes / (Number(endTime - startTime) / 1000_000_000)).toFixed(2)
)
};
}

0 comments on commit 0f8c17d

Please sign in to comment.