diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 29f29b7..716afd9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,8 +32,8 @@ jobs: - name: npm test run: npm test -# - name: npx semantic-release -# run: npx semantic-release -# env: -# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -# NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: npx semantic-release + run: npx semantic-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/BENCHMARKS.md b/BENCHMARKS.md new file mode 100644 index 0000000..0476ddd --- /dev/null +++ b/BENCHMARKS.md @@ -0,0 +1,56 @@ +# Benchmarks +## Running benchmarks + +Benchmarks are run using the [cronometro](https://www.npmjs.com/package/cronometro) module. + +First start the cluster +```sh +cd benchmarks +PORT=3030 node server +``` +See [server options](#server-options) for details on how to start the server. + +Open a new terminal session and run the benchmarks: +```sh +PORT=3030 node benchmark.js +``` +You should see something similar to this: +```sh +┌──────────────────────────────┬─────────┬──────────────────┬───────────┬─────────────────────────┐ +│ Tests │ Samples │ Result │ Tolerance │ Difference with slowest │ +|──────────────────────────────|─────────|──────────────────|───────────|─────────────────────────| +│ http - no keepalive │ 1 │ 1563.48 req/sec │ ± 0.00 % │ - │ +|──────────────────────────────|─────────|──────────────────|───────────|─────────────────────────| +│ fetch │ 1 │ 3702.40 req/sec │ ± 0.00 % │ + 136.81 % │ +|──────────────────────────────|─────────|──────────────────|───────────|─────────────────────────| +│ http - keepalive │ 1 │ 5509.85 req/sec │ ± 0.00 % │ + 252.41 % │ +|──────────────────────────────|─────────|──────────────────|───────────|─────────────────────────| +│ undici - pipeline │ 1 │ 6413.69 req/sec │ ± 0.00 % │ + 310.22 % │ +|──────────────────────────────|─────────|──────────────────|───────────|─────────────────────────| +│ undici - request │ 1 │ 8025.89 req/sec │ ± 0.00 % │ + 413.34 % │ +|──────────────────────────────|─────────|──────────────────|───────────|─────────────────────────| +│ podium-http-client - request │ 1 │ 80482.90 req/sec │ ± 0.00 % │ + 5047.68 % │ +└──────────────────────────────┴─────────┴──────────────────┴───────────┴─────────────────────────┘ +``` + +See [benchmark options](#benchmark-options) for details options when running the benchmark. + +### Server options + +| Name | Default | Description | +|-----------|---------------------|------------------------------| +| `PORT` | `3030` | Server port | +| `TIMEOUT` | `1` | Server delay | +| `WORKERS` | `os.cpus().length` | Number of workers to spin up | + +### Benchmark options + +| Name | Default | Description | +|-----------------|----- ---|--------------------------------| +| `SAMPLES` | `1` | Number of iterations pr sample | +| `ERROR_TRESHOLD` | `3` | Benchmark error threshold | +| `CONNECTIONS` | `50` | Undici: max sockets | +| `PIPELINING` | `10` | Unidici: pipelining config | +| `PARALLEL` | `100` | Number of workers to spawn | +| `HEADERS_TIMEOUT` | `0` | Undici: headers timeout | +| `BODY_TIMEOUT` | `0` | Unidic: body timeout | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5e3b41c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# [1.0.0-beta.9](https://github.com/podium-lib/http-client/compare/v1.0.0-beta.8...v1.0.0-beta.9) (2025-01-13) + + +### Bug Fixes + +* upgrading to undici v7 ([8c67e3d](https://github.com/podium-lib/http-client/commit/8c67e3dc67db3a7085898aa287c01b5e00f6e8f2)) + +# [1.0.0-beta.8](https://github.com/podium-lib/http-client/compare/v1.0.0-beta.7...v1.0.0-beta.8) (2024-11-12) + + +### Bug Fixes + +* bump ([f4ccca2](https://github.com/podium-lib/http-client/commit/f4ccca2ca82a78f278f1fc8f828074bb19eacb17)) + +# [1.0.0-beta.7](https://github.com/podium-lib/http-client/compare/v1.0.0-beta.6...v1.0.0-beta.7) (2024-11-12) + + +### Bug Fixes + +* making .request return the entire response ([97180e3](https://github.com/podium-lib/http-client/commit/97180e3fb8b135fcd0cc4810148701f48b9db1dd)) + +# [1.0.0-beta.6](https://github.com/podium-lib/http-client/compare/v1.0.0-beta.5...v1.0.0-beta.6) (2024-11-11) + + +### Bug Fixes + +* updating metrics to collect better things ([efe65e7](https://github.com/podium-lib/http-client/commit/efe65e7de5c3d4b3e11cb0f46a5adf9493cad51b)) + +# [1.0.0-beta.5](https://github.com/podium-lib/http-client/compare/v1.0.0-beta.4...v1.0.0-beta.5) (2024-11-07) + + +### Bug Fixes + +* adding back breaker metrics tests ([16b65b5](https://github.com/podium-lib/http-client/commit/16b65b513a1d6932316b781c4e675e0956a76691)) +* adjusting abort controller implementation ([5820d35](https://github.com/podium-lib/http-client/commit/5820d35fb0be8afd4c4886997ec07f69ebf0b5b5)) + +# [1.0.0-beta.4](https://github.com/podium-lib/http-client/compare/v1.0.0-beta.3...v1.0.0-beta.4) (2024-11-06) + + +### Bug Fixes + +* supporting more request options and metrics on breaker events ([1a62a07](https://github.com/podium-lib/http-client/commit/1a62a07de4c070738cebdd9f02a9e73c7fab784f)) +* supporting more request options and metrics on breaker events ([c5ea66b](https://github.com/podium-lib/http-client/commit/c5ea66b620cb5d5a3fec12b9be0735bb8d0ad6ee)) + +# [1.0.0-beta.3](https://github.com/podium-lib/http-client/compare/v1.0.0-beta.2...v1.0.0-beta.3) (2024-11-04) + + +### Bug Fixes + +* adding .metrics property and circuit breaker metrics ([3283f5f](https://github.com/podium-lib/http-client/commit/3283f5fbe139efeacd1cc67b0a576a3587b14acd)) + +# [1.0.0-beta.2](https://github.com/podium-lib/http-client/compare/v1.0.0-beta.1...v1.0.0-beta.2) (2024-11-01) + + +### Bug Fixes + +* removing custom error ([6f14c0a](https://github.com/podium-lib/http-client/commit/6f14c0a0f5e3c772ef78b4ec40ba2b6fd193a5a0)) + +# 1.0.0-beta.1 (2024-10-31) + + +### Features + +* Initial commit ([bfc0570](https://github.com/podium-lib/http-client/commit/bfc05709128b591b3d71aeddc3a30749cd2cf4cf)) diff --git a/README.md b/README.md index 45373bc..b927d97 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,192 @@ # @podium/http-client -⚠️ This project is still work in progress, should not be used for anything just yet. +⚠️ This project is in beta, use at your own risk. Changes might occur. -Generic http client built on [undici](undici.nodejs.org/) with a circuit breaker, error handling and metrics out of the box. - -[![Dependencies](https://img.shields.io/david/podium-lib/http-client.svg)](https://david-dm.org/podium-lib/http-client) [![GitHub Actions status](https://github.com/podium-lib/http-client/workflows/Run%20Lint%20and%20Tests/badge.svg)](https://github.com/podium-lib/layout/actions?query=workflow%3A%22Run+Lint+and+Tests%22) [![Known Vulnerabilities](https://snyk.io/test/github/podium-lib/http-client/badge.svg)](https://snyk.io/test/github/podium-lib/http-client) +`@podium/http-client` is a general purpost http client built on [undici] with a circuit breaker using [opossum], error handling and metrics out of the box. +It is used in [@podium/client] and provides the formentioned capabilities to the [@podium/layout] module. + +There is nothing podium specific in the client, which means that you can use it anywhere you do a `fetch` or use some other library to get or send content over HTTP. With the circuit breaking capability, it is ideal for consuming services in a distrubuted system. + +**Table of contents:** + + - [Installing](#installation) + - [Usage](#usage) + - [API](#api) + - [Constructor](#constructor) + - [Methods](#methods) + - Exposed [metrics](#metrics) + - [Benchmarks](./BENCHMARKS.md) + ## Installation +*Note!* Requires Node.js v20 or later. + ```bash -$ npm install @podium/http-client +npm install @podium/http-client ``` ## Usage +Here is how to instantiate the client to make a request for the configured resource. +```js +import client from '@podium/http-client'; +const client = new HttpClient(options); + +const response = await client.request({ path: '/', origin: 'https://host.domain' }) +if (response.ok) { + // +} + +await client.close(); +``` +Note that you have to call `.close` when the request is + +### Aborting requests +If for whatever reason you want to be able to manually abort a request, you can send in an `AbortController`. ```js +import client from '@podium/http-client'; +const client = new HttpClient(options); + +const controller = new AbortController(); +const response = await client.request({ + path: '/', + origin: 'https://host.domain', + signal: controller.signal +}); +// Abort the request. +controller.abort(); +await client.close(); +``` + +## API +### Constructor + +```js +import client from '@podium/http-client'; +const client = new HttpClient(options); ``` -## Constructor +#### options + +| option | default | type | required | details | +|---------------------|--------------|------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------| +| clientName | `''` | `string` | no | Client name | +| connections | `50` | `number` | no | See [connections](#connections) | +| keepAliveMaxTimeout | `undefined` | `number` | no | See [keepAliveMaxTimeout](#keepAliveMaxTimeout) | +| keepAliveTimeout | `undefined` | `number` | no | See [keepAliveTimeout](#keepAliveTimeout) | +| logger | `undefined ` | `object` | no | A logger which conform to a log4j interface | +| pipelining | `10` | `number` | no | See [pipelining](#pipelining) | +| reset | `2000` | `number` | no | Circuit breaker: How long, in milliseconds, to wait before a tripped circuit should be reset. | +| threshold | `25` | `number` | no | Circuit breaker: How many, in %, requests should error before the circuit should trip. Ex; when 25% of requests fail, trip the circuit. | +| throwOn400 | `false` | `boolean` | no | If the client should throw on HTTP 400 errors.If true, HTTP 400 errors will counts against tripping the circuit. | +| throwOn500 | `true` | `boolean` | no | If the client should throw on HTTP 500 errors.If true, HTTP 500 errors will counts against tripping the circuit. | +| timeout | `500` | `number` | no | Circuit breaker: How long, in milliseconds, a request can maximum take. Requests exceeding this limit counts against tripping the circuit. | + + +##### connections + +Property is sent to the underlying http library. +See library docs on [connections](https://undici.nodejs.org/#/docs/api/Pool?id=parameter-pooloptions) + + +##### keepAliveMaxTimeout + +Property is sent to the underlying http library. +See library docs on [keepAliveTimeout](https://undici.nodejs.org/#/docs/api/Client?id=parameter-clientoptions) + +##### keepAliveMaxTimeout + +Property is sent to the underlying http library. +See library docs on [keepAliveMaxTimeout](https://undici.nodejs.org/#/docs/api/Client?id=parameter-clientoptions) + +##### logger + +Any log4j compatible logger can be passed in and will be used for logging. +Console is also supported for easy test / development. + +Example: + +```js +import client from '@podium/http-client'; +const client = new HttpClient({ + logger: abslog(/* your logging library of choice **/), +}); +``` + +Under the hood [abslog] is used to abstract out logging. Please see [abslog] for +further details. + +##### pipelining + +Property is sent to the underlying http library. +See library docs on [pipelining](https://undici.nodejs.org/#/?id=pipelining) + +##### reset +Circuit breaker: How long, in milliseconds, to wait before a tripped circuit should be reset. + +##### threshold + +Circuit breaker: How many, in %, requests should error before the circuit should trip. Ex; when 25% of requests fail, trip the circuit. + +##### timeout +Circuit breaker: How long, in milliseconds, a request can maximum take. Requests exceeding this limit counts against tripping the circuit. + +##### throwOn400 + +If the client should throw on http 400 errors. If true, http 400 errors will count against tripping the circuit. + +##### throwOn500 +If the client should throw on http 500 errors. If true, http 500 errors will count against tripping the circuit. + +### Methods + +#### async request(options = {}) + +Sends a request using the passed in options object. + +| name | type | description | +|-----------------|-----------------|-------------------------------------------------| +| headers | `object` | Object with key / value which are strings | +| method | `string` | HTTP method name | +| origin | `string \| URL` | Request origin, ex `https://server.domain:9090` | +| path | `string` | URL path, ex `/foo` | +| query | `object` | Object with key / value which are strings | +| redirectable | `boolean` | If we should follow redirects or not. | +| maxRedirections | `number` | Max number of redirects. | +| signal | `AbortSignal` | Abort signal for canceling requests. | +| throwable | `boolean` | If we should throw on errors. | + +For a complete list of options, consult the [undici documentation](https://undici.nodejs.org/#/?id=undicirequesturl-options-promise). + +#### async close() + +Closes the client and all open connections. + +### Metrics + +The client expose a number of [@metrics/metric] to enabled developers to monitor its behaviour. + +| name | type | description | +|---------------------------------|---------------|-----------------------------------------------------| +| `http_client_breaker_events` | `counter` | See [breaker metrics](#breaker-metrics) | +| `http_client_request_error` | `counter` | Counters the number of requests returning an error. | +| `http_client_request_duration` | `histogram` | Times the duration of all http requests. | + +See the [MetricsJS](https://metrics-js.github.io) project for more documentation on the individual metrics. + +#### Breaker metrics + +The client has metrics for the following events: `open`, `closed`, `rejected`. +See [opossum] for more details on these events. + +[@metrics/metric]: https://metrics-js.github.io/reference/metric/ +[@podium/client]: https://github.com/podium-lib/client +[@podium/layout]: https://github.com/podium-lib/layout +[abslog]: https://github.com/trygve-lie/abslog 'abslog' +[undici]: https://undici.nodejs.org/ +[opossum]: https://github.com/nodeshift/opossum/ diff --git a/benchmarks/benchmark.js b/benchmarks/benchmark.js index ff621f5..9abc1ee 100644 --- a/benchmarks/benchmark.js +++ b/benchmarks/benchmark.js @@ -10,7 +10,7 @@ import { isMainThread } from 'node:worker_threads'; import { Pool, Client, Agent, setGlobalDispatcher } from 'undici'; import PodiumHttpClient from '../lib/http-client.js'; -const podium = new PodiumHttpClient(); +const httpClient = new PodiumHttpClient(); const iterations = (parseInt(process.env.SAMPLES, 10) || 10) + 1; const errorThreshold = parseInt(process.env.ERROR_TRESHOLD, 10) || 3; @@ -78,35 +78,6 @@ const dispatcher = new Class(httpBaseOptions.url, { setGlobalDispatcher(new Agent({ pipelining, connections })); -// eslint-disable-next-line no-unused-vars -class SimpleRequest { - constructor(resolve) { - this.dst = new Writable({ - write(chunk, encoding, callback) { - callback(); - }, - }).on('finish', resolve); - } - // eslint-disable-next-line no-unused-vars - onConnect(abort) {} - - onHeaders(statusCode, headers, resume) { - this.dst.on('drain', resume); - } - - onData(chunk) { - return this.dst.write(chunk); - } - - onComplete() { - this.dst.end(); - } - - onError(err) { - throw err; - } -} - function makeParallelRequests(cb) { return Promise.all( Array.from(Array(parallelRequests)).map(() => new Promise(cb)), @@ -143,7 +114,11 @@ function printResults(results) { ]; }); - console.log(results); + for (const key of Object.keys(results)) { + if (!results[key].success) { + console.error(`${key} errored: ${results[key].error}`); + } + } // Add the header row rows.unshift([ @@ -172,15 +147,7 @@ function printResults(results) { alignment: 'right', }, }, - drawHorizontalLine: (index, size) => index > 0 && index < size, - border: { - bodyLeft: '│', - bodyRight: '│', - bodyJoin: '│', - joinLeft: '|', - joinRight: '|', - joinJoin: '|', - }, + //drawHorizontalLine: (index, size) => index > 0 && index < size, }); } @@ -196,7 +163,7 @@ const experiments = { }), ).on('finish', resolve); }); - }).catch(console.log); + }).catch(console.error); }, 'http - keepalive'() { @@ -210,12 +177,12 @@ const experiments = { }), ).on('finish', resolve); }); - }).catch(console.log); + }).catch(console.error); }, fetch() { return makeParallelRequests((resolve) => { - fetch('http://localhost:3042') + fetch(`http://localhost:${dest.port}`) .then((res) => { res.body.pipeTo( new WritableStream({ @@ -226,7 +193,7 @@ const experiments = { }), ); }) - .catch(console.log); + .catch(console.error); }); }, @@ -245,7 +212,7 @@ const experiments = { }), ) .on('finish', resolve); - }).catch(console.log); + }).catch(console.error); }, 'undici - request'() { @@ -261,28 +228,32 @@ const experiments = { }), ).on('finish', resolve); }) - .catch(console.log); + .catch(console.error); }); }, - 'podium-http - request'() { - return makeParallelRequests((resolve) => { - podium.request('http://localhost:3042').then(({ body }) => { - body.pipe( - new Writable({ - write(chunk, encoding, callback) { - callback(); - }, - }), - ).on('finish', resolve); - }); - }).catch(console.log); + async 'podium-http-client'() { + makeParallelRequests((resolve) => { + httpClient + .request({ + origin: `http://localhost:${dest.port}`, + }) + .then(({ body }) => { + body.pipe( + new Writable({ + write(chunk, encoding, callback) { + callback(); + }, + }), + ).on('finish', resolve); + }); + }).catch(console.error); + return; }, }; async function main() { const { cronometro } = await import('cronometro'); - cronometro( experiments, { @@ -306,6 +277,8 @@ let foolMain; if (isMainThread) { // console.log('I am in main thread'); main(); + await httpClient.close(); + //return; } else { foolMain = main; diff --git a/benchmarks/server.js b/benchmarks/server.js index 11795ff..7cd0d22 100644 --- a/benchmarks/server.js +++ b/benchmarks/server.js @@ -21,6 +21,7 @@ if (cluster.isPrimary) { for (let i = 0; i < workers; i++) { cluster.fork(); } + console.log(`${workers} workers available`); } else { const buf = Buffer.alloc(64 * 1024, '_'); const server = createServer((req, res) => { @@ -28,5 +29,9 @@ if (cluster.isPrimary) { res.end(buf); }, timeout); }).listen(port); + server.on('error', (error) => { + console.error(error); + }); server.keepAliveTimeout = 600e3; + console.log(` - worker ${process.pid} started`); } diff --git a/benchmarks/wait.js b/benchmarks/wait.js deleted file mode 100644 index 157a36a..0000000 --- a/benchmarks/wait.js +++ /dev/null @@ -1,20 +0,0 @@ -import waitOn from 'wait-on'; -import path from 'path'; -import os from 'os'; - -const socketPath = path.join(os.tmpdir(), 'undici.sock'); - -let resources; -if (process.env.PORT) { - resources = [`http-get://localhost:${process.env.PORT}/`]; -} else { - resources = [`http-get://unix:${socketPath}:/`]; -} - -waitOn({ - resources, - timeout: 5000, -}).catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/examples/abort.js b/examples/abort.js new file mode 100644 index 0000000..62cf54c --- /dev/null +++ b/examples/abort.js @@ -0,0 +1,27 @@ +import HttpClient from '../lib/http-client.js'; +const url = 'https://jsonplaceholder.typicode.com/todos/1'; + +const controller = new AbortController(); +const signal = controller.signal; + +const fetchTodo = async () => { + try { + const client = new HttpClient({ + timeout: 10000, + abortController: controller, + }); + const u = new URL(url); + client.request({ + origin: u.origin, + path: u.pathname, + signal, + }); + // eslint-disable-next-line no-unused-vars + } catch (e) { + console.log('e'); + } +}; + +fetchTodo(); + +controller.abort(); diff --git a/examples/failing.js b/examples/failing.js new file mode 100644 index 0000000..468f201 --- /dev/null +++ b/examples/failing.js @@ -0,0 +1,28 @@ +import HttpClient from '../lib/http-client.js'; + +/** + * Example of the circuit breaker opening on a failing request. + */ +const client = new HttpClient(); +try { + await client.request({ + origin: 'http://localhost:9099', + path: '/', + method: 'GET', + }); + // eslint-disable-next-line no-unused-vars +} catch (_) { + // Mute the first one, next request should hit an open breaker +} + +try { + await client.request({ + origin: 'http://localhost:9099', + path: '/', + method: 'GET', + }); +} catch (err) { + if (err.toString() === 'Error: Breaker is open') { + console.error('Server unavailable..'); + } +} diff --git a/examples/success.js b/examples/success.js new file mode 100644 index 0000000..1db3f5e --- /dev/null +++ b/examples/success.js @@ -0,0 +1,11 @@ +import HttpClient from '../lib/http-client.js'; + +const client = new HttpClient(); +const response = await client.request({ + origin: 'https://www.google.com', + path: '/', + method: 'GET', + maxRedirections: 0, +}); + +console.log(response.statusCode); diff --git a/lib/http-client.js b/lib/http-client.js index a34156a..7ff38b7 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -1,115 +1,227 @@ -import { Agent, setGlobalDispatcher, request, MockAgent } from 'undici'; +import { Agent, interceptors, request } from 'undici'; import createError from 'http-errors'; import Opossum from 'opossum'; import abslog from 'abslog'; +import Metrics from '@metrics/client'; /** + * @typedef HttpClientRequestOptionsAdditions + * @property {AbortSignal} [signal] + * + * @typedef FollowRedirectsOptions + * @property {boolean} [enabled=false] + * @property {boolean} [maxRedirections=2] + * @property {boolean} [throwOnMaxRedirects=true] + * + * @typedef {import('undici').Dispatcher.ResponseData} HttpClientResponse + * + * @typedef {import('undici').Dispatcher.DispatchOptions & HttpClientRequestOptionsAdditions} HttpClientRequestOptions + * * @typedef HttpClientOptions - * @property {Number} keepAliveMaxTimeout - * @property {Number} keepAliveTimeout - * @property {Number} connections - * @property {Number} pipelining - * @property {Boolean} throwOn400 - If the client should throw on http 400 errors. If true, http 400 errors will counts against tripping the circuit. - * @property {Boolean} throwOn500 - If the client should throw on http 500 errors. If true, http 500 errors will counts against tripping the circuit. - * @property {Number} threshold - Circuit breaker: How many, in %, requests should error before the circuit should trip. Ex; when 25% of requests fail, trip the circuit. - * @property {Number} timeout - Circuit breaker: How long, in milliseconds, a request can maximum take. Requests exceeding this limit counts against tripping the circuit. - * @property {import('abslog')} logger - A logger instance compatible with abslog . - * @property {Number} reset - Circuit breaker: How long, in milliseconds, to wait before a tripped circuit should be reset. + * @property {string} [clientName] - client name + * @property {Number} [connections=50] - @see https://undici.nodejs.org/#/docs/api/Pool?id=parameter-pooloptions + * @property {Number} [keepAliveMaxTimeout] - @see https://undici.nodejs.org/#/docs/api/Client?id=parameter-clientoptions + * @property {Number} [keepAliveTimeout] - @see https://undici.nodejs.org/#/docs/api/Client?id=parameter-clientoptions + * @property {import('abslog')} [logger] - A logger instance compatible with abslog . + * @property {Number} [pipelining=10] - @see https://undici.nodejs.org/#/?id=pipelining + * @property {Number} [reset=20000] - Circuit breaker: How long, in milliseconds, to wait before a tripped circuit should be reset. + * @property {Number} [threshold=25] - Circuit breaker: How many, in %, requests should error before the circuit should trip. Ex; when 25% of requests fail, trip the circuit. + * @property {Boolean} [throwOn400=false] - If the client should throw on http 400 errors. If true, http 400 errors will count against tripping the circuit. + * @property {Boolean} [throwOn500=true] - If the client should throw on http 500 errors. If true, http 500 errors will count against tripping the circuit. + * @property {Number} [timeout=500] - Circuit breaker: How long, in milliseconds, a request can maximum take. Requests exceeding this limit counts against tripping the circuit. **/ -export default class PodiumHttpClient { +/** + * Client for making HTTP requests, comes with a built-in circuit breaker functionality to prevent + * performing denial of service attacks when target URL is down. + */ +export default class HttpClient { + #agent; + /** @type {import('opossum')} */ #breaker; + #breakerCounter; + #logger; + #clientName; + #metrics = new Metrics(); + #errorCounter; + #requestDuration; #throwOn400; #throwOn500; - #breaker; - #logger; - #agent; /** - * @property {HttpClientOptions} options - options + * @property {HttpClientOptions} options - client options */ constructor({ + clientName = '', + connections = 50, keepAliveMaxTimeout = undefined, keepAliveTimeout = undefined, - connections = 50, + logger = undefined, pipelining = 10, + reset = 20000, throwOn400 = false, - throwOn500 = true, + throwOn500 = false, threshold = 25, timeout = 500, - logger = undefined, - reset = 20000, } = {}) { this.#logger = abslog(logger); + this.#clientName = clientName; this.#throwOn400 = throwOn400; this.#throwOn500 = throwOn500; - // TODO; Can we avoid bind here in a nice way????? this.#breaker = new Opossum(this.#request.bind(this), { - errorThresholdPercentage: threshold, // When X% of requests fail, trip the circuit - resetTimeout: reset, // After X milliseconds, try again. - timeout, // If our function takes longer than X milliseconds, trigger a failure + errorThresholdPercentage: threshold, + resetTimeout: reset, + timeout, }); + this.#errorCounter = this.#metrics.counter({ + name: 'http_client_request_error', + description: 'Requests resulting in an error', + }); + + this.#requestDuration = this.#metrics.histogram({ + name: 'http_client_request_duration', + description: 'Request duration', + labels: { + method: undefined, + status: undefined, + url: undefined, + }, + buckets: [0.001, 0.01, 0.1, 0.5, 1, 2, 10], + }); + + this.#breakerCounter = this.#metrics.counter({ + name: 'http_client_breaker_events', + description: 'Metrics on breaker events', + }); + + // See https://github.com/nodeshift/opossum/?tab=readme-ov-file#events + // for details on events. + for (const eventName of this.#breaker.eventNames()) { + //@ts-ignore + const event = eventName; + //@ts-ignore + if (['open', 'close', 'reject'].includes(event)) { + //@ts-ignore + this.#breaker.on(event, (e) => { + this.#logger.debug( + `breaker event '${String(event)}', client name '${this.#clientName}'`, + ); + let obj = Array.isArray(e) ? e[0] : e; + this.#breakerCounter.inc({ + labels: { + //@ts-ignore + name: event, + ...(obj && obj.path && { path: obj.path }), + ...(obj && obj.origin && { origin: obj.origin }), + }, + }); + }); + } + } + this.#agent = new Agent({ - keepAliveMaxTimeout, // TODO unknown option, consider removing - keepAliveTimeout, // TODO unknown option, consider removing + keepAliveMaxTimeout, + keepAliveTimeout, connections, - pipelining, // TODO unknown option, consider removing + pipelining, }); } + /** + * Returns metrics object for the client. + * @returns {import('@metrics/client')} + */ + get metrics() { + return this.#metrics; + } + + /** + * Performs an http requests using the passed in options. + * @param {HttpClientRequestOptionsAdditions | Object} options + * @returns {Promise} + */ async #request(options = {}) { - const { statusCode, headers, trailers, body } = await request(options); + if (options.redirectable) { + const { redirect } = interceptors; + options.dispatcher = this.#agent.compose( + redirect({ maxRedirections: 1 }), + ); + } else { + options.dispatcher = this.#agent; + } - if (this.#throwOn400 && statusCode >= 400 && statusCode <= 499) { + const stopRequestTimer = this.#requestDuration.timer(); + const response = await request({ + ...options, + }); + const { statusCode, body } = response; + + stopRequestTimer({ + labels: { + method: options.method, + status: statusCode, + url: `${options.origin}${options.path}`, + }, + }); + + if (statusCode >= 400) { + this.#errorCounter.inc({ + labels: { + method: options.method, + status: statusCode, + url: options.url, + }, + }); + } + // TODO Look into this, as the resovlers have their own handling of this + // In order to be backward compatible, both throw options must be false + if ( + (this.#throwOn400 || options.throwable) && + statusCode >= 400 && + statusCode <= 499 + ) { // Body must be consumed; https://github.com/nodejs/undici/issues/583#issuecomment-855384858 const errBody = await body.text(); - this.#logger.debug( + this.#logger.trace( `HTTP ${statusCode} error catched by client. Body: ${errBody}`, ); throw createError(statusCode); } - if (this.#throwOn500 && statusCode >= 500 && statusCode <= 599) { + if ( + (this.#throwOn500 || options.throwable) && + statusCode >= 500 && + statusCode <= 599 + ) { // Body must be consumed; https://github.com/nodejs/undici/issues/583#issuecomment-855384858 - await body.text(); const errBody = await body.text(); - this.#logger.debug( + this.#logger.trace( `HTTP ${statusCode} error catched by client. Body: ${errBody}`, ); throw createError(statusCode); } - return { - statusCode, - headers, - trailers, - body, - }; - } - - fallback(fn) { - this.#breaker.fallback(fn); - } - - metrics() { - // TODO: Implement... + return response; } + /** + * Perform an http request using the passed in options. + * @param {HttpClientRequestOptions | Object} [options] + * @returns {Promise} + */ async request(options = {}) { return await this.#breaker.fire(options); } + /** + * Closes the http client and all it's connectsions. + * @returns {Promise} + */ async close() { await this.#breaker.close(); if (!this.#agent.destroyed && !this.#agent.closed) { await this.#agent.close(); } } - - static mock(origin) { - const agent = new MockAgent(); - setGlobalDispatcher(agent); - return agent.get(origin); - } } diff --git a/package.json b/package.json index d91f83b..535809e 100644 --- a/package.json +++ b/package.json @@ -1,55 +1,60 @@ { - "name": "@podium/http-client", - "version": "0.0.1", - "type": "module", - "description": "The HTTP client used for all HTTP requests in Podium", - "main": "lib/http-client.js", - "directories": { - "lib": "lib", - "test": "tests" - }, - "scripts": { - "bench": "PORT=3042 concurrently -k -s first npm:bench:server npm:bench:run", - "bench:run": "CONNECTIONS=1 node benchmarks/benchmark.js; CONNECTIONS=50 node benchmarks/benchmark.js", - "bench:server": "node benchmarks/server.js", - "lint": "eslint .", - "lint:fix": "eslint . --fix", - "prebench:run": "node benchmarks/wait.js", - "test": "node --test", - "test:watch": "node --test --watch", - "types": "run-s types:tsc types:test", - "types:tsc": "tsc", - "types:test": "tsc --project tsconfig.test.json" - }, - "repository": { - "type": "git", - "url": "git+ssh://git@github.com/podium-lib/http-client.git" - }, - "author": "Trygve Lie", - "license": "MIT", - "bugs": { - "url": "https://github.com/podium-lib/http-client/issues" - }, - "homepage": "https://github.com/podium-lib/http-client#readme", - "dependencies": { - "@metrics/client": "2.5.3", - "abslog": "2.4.4", - "http-errors": "2.0.0", - "opossum": "8.3.0", - "undici": "6.20.1" - }, - "devDependencies": { - "@podium/eslint-config": "1.0.2", - "@podium/semantic-release-config": "2.0.0", - "@podium/typescript-config": "1.0.0", - "@types/node": "20.17.3", - "concurrently": "9.0.1", - "cronometro": "4.0.0", - "eslint": "9.13.0", - "prettier": "3.3.3", - "npm-run-all2": "6.2.6", - "table": "6.8.2", - "typescript": "5.6.3", - "wait-on": "8.0.1" - } + "name": "@podium/http-client", + "version": "1.0.0-beta.9", + "type": "module", + "description": "The HTTP client used for all HTTP requests in Podium", + "main": "lib/http-client.js", + "directories": { + "lib": "lib", + "test": "tests" + }, + "scripts": { + "bench": "PORT=3042 concurrently -k -s first npm:bench:server npm:bench:run", + "bench:run": "CONNECTIONS=1 node benchmarks/benchmark.js; CONNECTIONS=50 node benchmarks/benchmark.js", + "bench:server": "node benchmarks/server.js", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "prebench:run": "node benchmarks/wait.js", + "test": "node --test", + "test:watch": "node --test --watch", + "types": "run-s types:tsc types:test", + "types:tsc": "tsc", + "types:test": "tsc --project tsconfig.test.json" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/podium-lib/http-client.git" + }, + "author": "Trygve Lie", + "license": "MIT", + "bugs": { + "url": "https://github.com/podium-lib/http-client/issues" + }, + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/podium-lib/http-client#readme", + "dependencies": { + "@metrics/client": "2.5.4", + "abslog": "2.4.4", + "http-errors": "2.0.0", + "opossum": "8.4.0", + "undici": "7.2.1" + }, + "devDependencies": { + "@podium/eslint-config": "1.0.5", + "@podium/semantic-release-config": "2.0.0", + "@podium/typescript-config": "1.0.0", + "@types/node": "22.10.5", + "@types/opossum": "8.1.8", + "@types/readable-stream": "4.0.18", + "concurrently": "9.1.2", + "cronometro": "4.0.2", + "eslint": "9.18.0", + "npm-run-all2": "7.0.2", + "prettier": "3.4.2", + "table": "6.9.0", + "typescript": "5.7.3", + "wait-on": "8.0.2" + } } diff --git a/tests/http-client.test.js b/tests/http-client.test.js index 6237ccf..26c8fb1 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -1,88 +1,374 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; +import { after, before, beforeEach, test } from 'node:test'; +import { notStrictEqual, ok, rejects, strictEqual } from 'node:assert/strict'; import http from 'node:http'; -import PodiumHttpClient from '../lib/http-client.js'; +import HttpClient from '../lib/http-client.js'; +import { wait } from './utilities.js'; let httpServer, host = 'localhost', port = 3003; +const url = `http://${host}:${port}`; -async function afterEach(client) { - await client.close(); - await httpServer.close(); -} - -test('http-client - basics', async (t) => { - t.beforeEach(async function () { +function startServer() { + return new Promise((resolve) => { httpServer = http.createServer(async (request, response) => { - response.writeHead(200); + if (request.url === '/not-found') { + response.writeHead(404); + } else { + response.writeHead(200); + } response.end(); }); - httpServer.listen(port, host, () => Promise.resolve()); + httpServer.listen(port, host, () => { + resolve(); + }); + }); +} + +function closeServer(s) { + return new Promise((resolve) => { + s.close(resolve); + }); +} + +async function stopServer(client, server = httpServer) { + if (client) { + await client.close(); + } + await closeServer(server); +} + +async function queryUrl({ + client, + path = '/', + url = `http://${host}:${port}`, + loop = 2, + suppressErrors = true, +}) { + const errors = []; + for (let i = 0; i <= loop; i++) { + try { + await client.request({ path, origin: url, method: 'GET' }); + } catch (err) { + // Push the actual cause here for the tests + if (!suppressErrors) errors.push(err); + } + } + if (errors.length > 0) { + throw new Error(errors.toString()); + } +} + +await test('http-client - basics', async (t) => { + const url = `http://${host}:2001`; + const server = http.createServer(async (request, response) => { + response.writeHead(200); + response.end(); + }); + server.listen(2001, host); + await t.test('returns 200 response when given valid input', async () => { + const client = new HttpClient(); + const response = await client.request({ + path: '/', + origin: url, + method: 'GET', + }); + strictEqual(response.statusCode, 200); + await client.close(); }); - await t.test( - 'http-client: returns 200 response when given valid input', - async () => { - const url = `http://${host}:${port}`; - const client = new PodiumHttpClient(); - const response = await client.request({ - path: '/', - origin: url, - method: 'GET', - }); - assert.strictEqual(response.statusCode, 200); - await afterEach(client); - }, - ); await t.test('does not cause havoc with built in fetch', async () => { - const url = `http://${host}:${port}`; - const client = new PodiumHttpClient(); + const client = new HttpClient(); await fetch(url); const response = await client.request({ path: '/', origin: url, method: 'GET', }); - assert.strictEqual(response.statusCode, 200); + strictEqual(response.statusCode, 200); await client.close(); await fetch(url); - await afterEach(client); + await client.close(); }); - test.skip('http-client: should not invalid port input', async () => { - const url = `http://${host}:3013`; - const client = new PodiumHttpClient(); - await client.request({ - path: '/', - origin: url, + await closeServer(server); +}); + +await test('http-client - timeouts', async (t) => { + // let slowServer; + // async function b() { + // // Slow responding server to enable us to abort a request + // slowServer = http.createServer(async (request, response) => { + // await wait(3000); + // response.writeHead(200); + // response.end(); + // }); + // slowServer.listen(2010, host); + // } + // function a() { + // try { + // slowServer.close(() => console.log('server closed...')); + // slowServer.closeAllConnections(); + // } catch (e) { + // console.log('**___***', e); + // } + // } + // await t.test('can cancel a request with an abort controller', async () => { + // await b(); + // const controller = new AbortController(); + // const client = new HttpClient({ timeout: 10000 }); + // const performRequest = async () => { + // try { + // await client.request({ + // path: '/', + // origin: 'http://localhost:2010', + // method: 'GET', + // signal: controller.signal, + // }); + // } catch (e) { + // // + // console.error('ess'); + // } + // }; + // performRequest(); + // controller.abort(); + // ok(controller.signal.aborted); + // await client.close(); + // await a(); + // }); + + await t.test('can cancel a request with an abort controller', async () => { + const abortController = new AbortController(); + const client = new HttpClient({ timeout: 100 }); + await rejects(async () => { + await client.request({ + path: '/', + origin: 'http://localhost:2010', + method: 'GET', + signal: abortController.signal, + }); + await client.close(); + }, 'RequestAasasortkmlklmlkmedErrsor'); + }); + + // await t.test('auto renew an abort controller', async () => { + // const abortController = new AbortController(); + // const client = new HttpClient({ timeout: 2000 }); + // await client.request({ + // autoRenewAbortController: true, + // path: '/', + // origin: 'http://localhost:2010', + // method: 'GET', + // }); + // await client.close(); + // }); +}); + +await test('http-client - redirects', async (t) => { + let to, from; + before(async function () { + to = http.createServer(async (request, response) => { + response.writeHead(200); + response.end(); + }); + to.listen(3033, host); + from = http.createServer(async (request, response) => { + if (request.url === '/redirect') { + response.setHeader('location', 'http://localhost:3033'); + } + response.writeHead(301); + response.end(); + }); + from.listen(port, host); + }); + after(function () { + from.close(); + to.close(); + }); + + await t.test('redirectable true follows redirects', async () => { + const client = new HttpClient(); + const response = await client.request({ method: 'GET', + origin: `http://${host}:${port}`, + path: '/redirect', + redirectable: true, }); + await client.close(); + strictEqual(response.statusCode, 200); + }); + // await t.test.skip('throw on max redirects', async () => {}); + await t.test('does not follow redirects by default', async () => { + const client = new HttpClient(); + const response = await client.request({ + method: 'GET', + origin: `http://${host}:${port}`, + path: '/redirect', + }); + strictEqual(response.statusCode, 301); + await client.close(); + }); +}); + +await test('http-client - circuit breaker behaviour', async (t) => { + beforeEach(startServer); + await t.test('opens on failure threshold', async () => { + const invalidUrl = `http://${host}:3013`; + const client = new HttpClient({ threshold: 50 }); + + let broken = 0; + for (let i = 0; i < 5; i++) { + try { + await client.request({ + path: '/', + origin: invalidUrl, + method: 'GET', + }); + } catch (err) { + if (err.toString() === 'Error: Breaker is open') { + broken++; + } + } + } + strictEqual( + broken, + 4, + `breaker open on 4 out of 5 requests, was ${broken}`, + ); + await client.close(); + await stopServer(client); + }); + + await t.test('can reset breaker', async () => { + const invalidUrl = `http://${host}:3023`; + const breakerReset = 10; + const client = new HttpClient({ threshold: 50, reset: breakerReset }); + let isOpen = false; + try { + await queryUrl({ + client, + loop: 4, + url: invalidUrl, + suppressErrors: false, + }); + } catch (err) { + if (err.toString().indexOf('Breaker is open') !== -1) { + isOpen = true; + } + } + strictEqual(isOpen, true, `breaker opened, ${isOpen}`); + await wait(breakerReset + 10); // wait for the breaker to close const response = await client.request({ path: '/', origin: url, method: 'GET', }); - assert.strictEqual(response.statusCode, 200); + strictEqual(response.statusCode, 200); + await client.close(); + await stopServer(client); }); }); -test.skip('http-client circuit breaker behaviour', async (t) => { - await t.test('closes on failure threshold', async () => { - const url = `http://${host}:3014`; - const client = new PodiumHttpClient({ threshold: 2 }); +await test('http-client: metrics', async (t) => { + await t.test('has a .metrics property', () => { + const client = new HttpClient(); + notStrictEqual(client.metrics, undefined); + }); + await t.test('request metrics', async () => { + await startServer(); + const client = new HttpClient(); + const metrics = []; + client.metrics.on('data', (metric) => { + metrics.push(metric); + }); + client.metrics.on('end', () => {}); await client.request({ path: '/', origin: url, method: 'GET', }); - const response = await client.request({ + await client.request({ + path: '/not-found', + origin: url, + method: 'GET', + }); + client.metrics.push(null); + client.metrics.on('end', () => { + const requestMetrics = metrics.filter( + (m) => + m.name === 'http_client_request_duration' || + m.name === 'http_client_request_error', + ); + strictEqual(requestMetrics.length, 3); + strictEqual(requestMetrics[0].name, 'http_client_request_duration'); + strictEqual(requestMetrics[0].type, 5); + strictEqual(requestMetrics[0].labels[0].name, 'method'); + strictEqual(requestMetrics[0].labels[0].value, 'GET'); + strictEqual(requestMetrics[0].labels[1].name, 'status'); + strictEqual(requestMetrics[0].labels[1].value, 200); + strictEqual(requestMetrics[0].labels[2].name, 'url'); + strictEqual( + requestMetrics[0].labels[2].value, + 'http://localhost:3003/', + ); + ok(requestMetrics[0].value > 0); + + strictEqual(requestMetrics[2].type, 2); + strictEqual(requestMetrics[2].name, 'http_client_request_error'); + strictEqual(requestMetrics[2].labels[0].name, 'method'); + strictEqual(requestMetrics[2].labels[0].value, 'GET'); + strictEqual(requestMetrics[2].labels[1].name, 'status'); + strictEqual(requestMetrics[2].labels[1].value, 404); + }); + await client.close(); + await stopServer(client); + }); + + await t.test('breaker metrics', async () => { + await startServer(); + const client = new HttpClient({ + reset: 10, + timeout: 1000, + throwOn400: false, + throwOn500: false, + }); + const metrics = []; + client.metrics.on('data', (metric) => { + metrics.push(metric); + }); + client.metrics.on('end', () => { + strictEqual(metrics.length, 3); + + strictEqual(metrics[0].name, 'http_client_breaker_events'); + strictEqual(metrics[0].type, 2); + strictEqual(metrics[0].labels[0].value, 'open'); + + strictEqual(metrics[1].name, 'http_client_request_duration'); + + strictEqual(metrics[2].name, 'http_client_breaker_events'); + strictEqual(metrics[2].type, 2); + strictEqual(metrics[2].labels[0].value, 'close'); + }); + try { + // Make the circuit open + await client.request({ + path: '/not-found', + origin: 'http://not.found.host:3003', + }); + // eslint-disable-next-line no-unused-vars + } catch (_) { + /* empty */ + } + await wait(10); + // Wait for circuit to reset, before using the client again. + await client.request({ path: '/', origin: url, method: 'GET', }); - assert.strictEqual(response.statusCode, 200); - await afterEach(client); + client.metrics.push(null); + await client.close(); + await stopServer(client); }); }); diff --git a/tests/utilities.js b/tests/utilities.js new file mode 100644 index 0000000..27c9fc7 --- /dev/null +++ b/tests/utilities.js @@ -0,0 +1,20 @@ +/** + * Wait a little bit, promise based. + * @property {Number} [waitTime=1000] + * @returns {Promise} + */ +export async function wait(waitTime = 1000) { + return new Promise((resolve) => { + setTimeout(async () => { + resolve(); + }, waitTime); + }); +} +export async function slowResponder(func, timeout = 1000) { + return new Promise((resolve) => { + setTimeout(async () => { + await func(); + resolve(); + }, timeout); + }); +}