diff --git a/README.md b/README.md index d027085..7d511e9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/41/README.md b/src/41/README.md new file mode 100644 index 0000000..aad2b10 --- /dev/null +++ b/src/41/README.md @@ -0,0 +1,44 @@ +# Challenge 41 - Write Your Own HTTP(S) Load Tester + +This challenge corresponds to the 41st 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 -u [-n ] [-c ] +``` + +### Options + +- `-u `: The URL on which load testing needs to be performed. + +- `-n `: The number of requests sent to the server. This are sent in series. Default = 10. + +- `-c `: 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 -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/ +``` diff --git a/src/41/__tests__/custom_request.test.ts b/src/41/__tests__/custom_request.test.ts new file mode 100644 index 0000000..8f51dad --- /dev/null +++ b/src/41/__tests__/custom_request.test.ts @@ -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()); + }); +}); diff --git a/src/41/custom_request.ts b/src/41/custom_request.ts new file mode 100644 index 0000000..b370b88 --- /dev/null +++ b/src/41/custom_request.ts @@ -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 { + 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((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(); + }); +} diff --git a/src/41/index.ts b/src/41/index.ts new file mode 100644 index 0000000..0cf05b1 --- /dev/null +++ b/src/41/index.ts @@ -0,0 +1,32 @@ +import { program } from 'commander'; +import { loadTester } from './load_tester'; + +program.option('-u ', 'URL to load test on'); +program.option('-n ', 'Number of requests to make', '10'); +program.option( + '-c ', + '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(); diff --git a/src/41/load_tester.ts b/src/41/load_tester.ts new file mode 100644 index 0000000..c659262 --- /dev/null +++ b/src/41/load_tester.ts @@ -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 { + 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 { + 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) + ) + }; +}