-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Write your own HTTP(S) Load Tester (#45)
* Completed till step 5 * updated global readme
- Loading branch information
1 parent
8f007d5
commit 0f8c17d
Showing
6 changed files
with
333 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/ | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
) | ||
}; | ||
} |