From 80ff297f30902c68a21b5d21cc1a1c3eaa55baf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Tue, 29 Oct 2024 09:46:14 +0100 Subject: [PATCH 01/32] baseline for beta version --- README.md | 150 ++++++++++++++++++++++- examples/failing.js | 30 +++++ examples/success.js | 11 ++ lib/http-client.js | 140 ++++++++++++++++------ package.json | 3 +- tests/http-client.test.js | 245 +++++++++++++++++++++++++++++++------- tests/utilities.js | 20 ++++ 7 files changed, 518 insertions(+), 81 deletions(-) create mode 100644 examples/failing.js create mode 100644 examples/success.js create mode 100644 tests/utilities.js diff --git a/README.md b/README.md index 45373bc..c339382 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,165 @@ ⚠️ This project is still work in progress, should not be used for anything just yet. -Generic http client built on [undici](undici.nodejs.org/) with a circuit breaker, error handling and metrics out of the box. +Generic http client built on [undici] with a circuit breaker using [opossum], 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) ## Installation +*Note!* Requires Node.js v20 or later. + ```bash -$ npm install @podium/http-client +npm install @podium/http-client ``` ## Usage +```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) { + // +} +``` + +## API + +### Constructor ```js +import client from '@podium/http-client'; + +const client = new HttpClient(options); +``` + +#### options + +| option | default | type | required | details | +|---------------------|--------------|------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------| +| abortController | `undefined` | `object` | no | See [abortController](#abortController) | +| connections | `50` | `number` | no | See [connections](#connections) | +| fallback | `undefined` | `function` | no | Function to call when requests fail, see [fallback](#fallback) | +| followRedirects | `false` | `boolean` | no | Flag for whether to follow redirects or not, see [followRedirects](#followRedirects). | +| 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. | + + +##### abortController + +Passing in an [AbortController](https://nodejs.org/docs/latest/api/globals.html#globals_class_abortcontroller) enables aborting requests. + +##### connections + +Property is sent to the underlying http library. +See library docs on [connections](https://undici.nodejs.org/#/docs/api/Pool?id=parameter-pooloptions) + +##### fallback + +Optional function to run when a request fails. + +```js +// TBA +``` + +##### followRedirects + +TODO!!! decide what to do with the redirects stuff... + +By default, the library does not follow redirect. +If set to true it will follow redirects according to `maxRedirections`. +It will by default throw on reaching `throwOnMaxRedirects` + + +##### 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 +const layout = new Layout({ + name: 'myLayout', + pathname: '/foo', + logger: console, +}); ``` -## Constructor +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 | +|---------|-----------------|-------------------------------------------------| +| origin | `string \| URL` | Request origin, ex `https://server.domain:9090` | +| path | `string` | URL path, ex `/foo` | +| method | `string` | HTTP method name | +| headers | `object` | Object with key / value which are strings | +| query | `object` | Object with key / value which are strings | +| signal | `AbortSignal` | Abort signal for canceling requests. | + +For a complete list of options, consult the [undici documentation](https://undici.nodejs.org/#/?id=undicirequesturl-options-promise). + + +### async close() + +Closes the client and it's connections. + +## HttpClientError + +Errors thrown from the library will be of the class `HttpClientError` + +[@metrics/metric]: https://github.com/metrics-js/metric '@metrics/metric' +[abslog]: https://github.com/trygve-lie/abslog 'abslog' +[undici]: https://undici.nodejs.org/ +[opossum]: https://github.com/nodeshift/opossum/ diff --git a/examples/failing.js b/examples/failing.js new file mode 100644 index 0000000..3bb780f --- /dev/null +++ b/examples/failing.js @@ -0,0 +1,30 @@ +import HttpClient, { HttpClientError } 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 instanceof HttpClientError) { + if (err.code === HttpClientError.ServerDown) { + 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..66908ab 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -1,70 +1,106 @@ -import { Agent, setGlobalDispatcher, request, MockAgent } from 'undici'; +import { Agent, request, interceptors } from 'undici'; import createError from 'http-errors'; import Opossum from 'opossum'; import abslog from 'abslog'; /** * @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 {Number} connections - @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 - @see https://undici.nodejs.org/#/?id=pipelining * @property {Number} reset - Circuit breaker: How long, in milliseconds, to wait before a tripped circuit should be reset. + * @property {Boolean} throwOn400 - If the client should throw on http 400 errors. If true, http 400 errors will count against tripping the circuit. + * @property {Boolean} throwOn500 - If the client should throw on http 500 errors. If true, http 500 errors will count 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 {Function} [fallaback=undefined] - Optional function to call as a fallback when breaker is open. **/ -export default class PodiumHttpClient { - #throwOn400; - #throwOn500; +export default class HttpClient { + #abortController; + #agent; #breaker; + #followRedirects; + #hasFallback = false; #logger; - #agent; + #throwOn400; + #throwOn500; /** * @property {HttpClientOptions} options - options */ constructor({ + abortController = undefined, + autoRenewAbortController = false, + connections = 50, + fallback = undefined, + followRedirects = false, keepAliveMaxTimeout = undefined, keepAliveTimeout = undefined, - connections = 50, + logger = undefined, pipelining = 10, + reset = 20000, throwOn400 = false, throwOn500 = true, threshold = 25, timeout = 500, - logger = undefined, - reset = 20000, } = {}) { this.#logger = abslog(logger); this.#throwOn400 = throwOn400; this.#throwOn500 = throwOn500; + this.#abortController = abortController; + this.#followRedirects = followRedirects; + // 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 + ...(this.#abortController && { + abortController: this.#abortController, + }), + ...(autoRenewAbortController && { autoRenewAbortController }), + errorThresholdPercentage: threshold, + resetTimeout: reset, + timeout, }); 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, }); + + if (fallback) { + this.#hasFallback = true; + if (typeof fallback === 'string') { + //@ts-ignore + this.#fallback(() => fallback); + } else { + this.#fallback(fallback); + } + } } async #request(options = {}) { - const { statusCode, headers, trailers, body } = await request(options); + if (this.#followRedirects) { + const { redirect } = interceptors; + options.dispatcher = this.#agent.compose( + redirect({ maxRedirections: 1 }), + ); + } else { + options.dispatcher = this.#agent; + } + + const { statusCode, headers, trailers, body } = await request({ + ...options, + }); if (this.#throwOn400 && 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); @@ -74,7 +110,7 @@ export default class PodiumHttpClient { // 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); @@ -88,28 +124,60 @@ export default class PodiumHttpClient { }; } - fallback(fn) { - this.#breaker.fallback(fn); - } - - metrics() { - // TODO: Implement... + /** + * Function called if the request fails. + * @param {import('opossum')} func + */ + #fallback(func) { + this.#breaker.fallback(func); } + /** + * Requests a URL. + * @param {any} [options] + * @returns {Promise} + */ async request(options = {}) { - return await this.#breaker.fire(options); + try { + return await this.#breaker.fire(options); + } catch (error) { + if (!this.#hasFallback) { + throw new HttpClientError( + `Error on ${options.method} ${options.origin}${options.path}`, + { + code: error.code, + cause: error, + options, + }, + ); + } + } } + /** + * Closes the client. + * @returns {Promise} + */ async close() { await this.#breaker.close(); if (!this.#agent.destroyed && !this.#agent.closed) { await this.#agent.close(); } } +} + +/** + * Error class for the client + */ +export class HttpClientError extends Error { + // Not sure if there is a need for this tbh, but I threw it in there so we can see how it feels. + static ServerDown = 'EOPENBREAKER'; - static mock(origin) { - const agent = new MockAgent(); - setGlobalDispatcher(agent); - return agent.get(origin); + constructor(message, { code, cause, options }) { + super(message); + this.name = 'HttpClientError'; + this.code = code; + this.cause = cause; + this.options = options; } } diff --git a/package.json b/package.json index d91f83b..20a71c6 100644 --- a/package.json +++ b/package.json @@ -43,11 +43,12 @@ "@podium/semantic-release-config": "2.0.0", "@podium/typescript-config": "1.0.0", "@types/node": "20.17.3", + "@types/opossum": "8.1.8", "concurrently": "9.0.1", "cronometro": "4.0.0", "eslint": "9.13.0", - "prettier": "3.3.3", "npm-run-all2": "6.2.6", + "prettier": "3.3.3", "table": "6.8.2", "typescript": "5.6.3", "wait-on": "8.0.1" diff --git a/tests/http-client.test.js b/tests/http-client.test.js index 6237ccf..fc58792 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -2,43 +2,71 @@ import test from 'node:test'; import assert 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; -async function afterEach(client) { +function beforeEach() { + httpServer = http.createServer(async (request, response) => { + response.writeHead(200); + response.end(); + }); + httpServer.listen(port, host); +} + +function closeServer(s) { + return new Promise((resolve) => { + s.close(resolve); + }); +} +async function afterEach(client, server = httpServer) { await client.close(); - await httpServer.close(); + await closeServer(server); } -test('http-client - basics', async (t) => { - t.beforeEach(async function () { - httpServer = http.createServer(async (request, response) => { - response.writeHead(200); - response.end(); +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.cause); + } + } + 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', }); - httpServer.listen(port, host, () => Promise.resolve()); + assert.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: '/', @@ -48,35 +76,172 @@ test('http-client - basics', async (t) => { assert.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 t.test('throws error when no fallback provided', async () => { + const client = new HttpClient(); + assert.rejects( + async () => { + await client.request({ + path: '/', + origin: 'https://does-not-exist.domain', + method: 'GET', + }); + }, + { + name: 'HttpClientError', + message: 'Error on GET https://does-not-exist.domain/', + }, + ); + await client.close(); + }); + await t.test('does not throw when fallback provided', async () => { + let isCaught = false; + const client = new HttpClient({ + fallback: () => { + isCaught = true; + }, + }); + await client.request({ path: '/', - origin: url, + origin: 'https://does-not-exist.domain', method: 'GET', }); - const response = await client.request({ + assert.strictEqual(isCaught, true); + await client.close(); + }); + + await closeServer(server); +}); + +await test('http-client - abort controller', async (t) => { + // Slow responding server to enable us to abort a request + const slowServer = http.createServer(async (request, response) => { + await wait(200); + response.writeHead(200); + response.end(); + }); + slowServer.listen(2010, host); + await t.test('cancel a request', async () => { + const abortController = new AbortController(); + let aborted = false; + setTimeout(() => { + abortController.abort(); + aborted = true; + }, 100); + const client = new HttpClient({ timeout: 2000 }); + await client.request({ path: '/', - origin: url, + origin: 'http://localhost:2010', method: 'GET', }); - assert.strictEqual(response.statusCode, 200); + assert.ok(aborted); + await client.close(); }); + + // 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(); + // }); + slowServer.close(); }); -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 client.request({ - path: '/', - origin: url, +await test('http-client - redirects', async (t) => { + const to = http.createServer(async (request, response) => { + response.writeHead(200); + response.end(); + }); + to.listen(3033, host); + + const 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); + + await t.test('can follow redirects', async () => { + const client = new HttpClient({ threshold: 50, followRedirects: true }); + const response = await client.request({ method: 'GET', + origin: `http://${host}:${port}`, + path: '/redirect', }); + assert.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({ threshold: 50 }); + const response = await client.request({ + method: 'GET', + origin: `http://${host}:${port}`, + path: '/redirect', + }); + assert.strictEqual(response.statusCode, 301); + }); + from.close(); + to.close(); +}); + +await test('http-client - circuit breaker behaviour', async (t) => { + const url = `http://${host}:${port}`; + await t.test('opens on failure threshold', async () => { + beforeEach(); + 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.cause.code === 'EOPENBREAKER') { + broken++; + } + } + } + assert.strictEqual( + broken, + 4, + `breaker open on 4 out of 5 requests, was ${broken}`, + ); + await afterEach(client); + }); + await t.test('can reset breaker', async () => { + beforeEach(); + 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; + } + } + assert.strictEqual(isOpen, true, `breaker opened, ${isOpen}`); + await wait(breakerReset + 10); // wait for the breaker to close const response = await client.request({ path: '/', origin: url, 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); + }); +} From ef3ca142698309be6d37b129e30bd646938446b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Thu, 31 Oct 2024 14:12:37 +0100 Subject: [PATCH 02/32] chore: updating publish workflow --- .github/workflows/publish.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 }} From d29635b759491e0f3d59e03fc8939f5f0549c218 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 31 Oct 2024 13:13:32 +0000 Subject: [PATCH 03/32] chore(release): 1.0.0-beta.1 [skip ci] # 1.0.0-beta.1 (2024-10-31) ### Features * Initial commit ([bfc0570](https://github.com/podium-lib/http-client/commit/bfc05709128b591b3d71aeddc3a30749cd2cf4cf)) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b0030b6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# 1.0.0-beta.1 (2024-10-31) + + +### Features + +* Initial commit ([bfc0570](https://github.com/podium-lib/http-client/commit/bfc05709128b591b3d71aeddc3a30749cd2cf4cf)) diff --git a/package.json b/package.json index 20a71c6..de2350a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@podium/http-client", - "version": "0.0.1", + "version": "1.0.0-beta.1", "type": "module", "description": "The HTTP client used for all HTTP requests in Podium", "main": "lib/http-client.js", From 1aab57c7d94ddb0af90781fdb6418774ccb3f09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Thu, 31 Oct 2024 15:00:39 +0100 Subject: [PATCH 04/32] chore: fixing package publish config --- package.json | 111 ++++++++++++++++++++++++++------------------------- 1 file changed, 57 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index de2350a..124de92 100644 --- a/package.json +++ b/package.json @@ -1,56 +1,59 @@ { - "name": "@podium/http-client", - "version": "1.0.0-beta.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", - "@types/opossum": "8.1.8", - "concurrently": "9.0.1", - "cronometro": "4.0.0", - "eslint": "9.13.0", - "npm-run-all2": "6.2.6", - "prettier": "3.3.3", - "table": "6.8.2", - "typescript": "5.6.3", - "wait-on": "8.0.1" - } + "name": "@podium/http-client", + "version": "1.0.0-beta.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" + }, + "publishConfig": { + "access": "public" + }, + "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", + "@types/opossum": "8.1.8", + "concurrently": "9.0.1", + "cronometro": "4.0.0", + "eslint": "9.13.0", + "npm-run-all2": "6.2.6", + "prettier": "3.3.3", + "table": "6.8.2", + "typescript": "5.6.3", + "wait-on": "8.0.1" + } } From 6f14c0a0f5e3c772ef78b4ec40ba2b6fd193a5a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Fri, 1 Nov 2024 11:00:50 +0100 Subject: [PATCH 05/32] fix: removing custom error --- README.md | 4 --- examples/failing.js | 8 +++--- lib/http-client.js | 57 +++++++++++++++++++-------------------- tests/http-client.test.js | 11 +++++--- 4 files changed, 37 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index c339382..eb02a24 100644 --- a/README.md +++ b/README.md @@ -156,10 +156,6 @@ For a complete list of options, consult the [undici documentation](https://undic Closes the client and it's connections. -## HttpClientError - -Errors thrown from the library will be of the class `HttpClientError` - [@metrics/metric]: https://github.com/metrics-js/metric '@metrics/metric' [abslog]: https://github.com/trygve-lie/abslog 'abslog' [undici]: https://undici.nodejs.org/ diff --git a/examples/failing.js b/examples/failing.js index 3bb780f..468f201 100644 --- a/examples/failing.js +++ b/examples/failing.js @@ -1,4 +1,4 @@ -import HttpClient, { HttpClientError } from '../lib/http-client.js'; +import HttpClient from '../lib/http-client.js'; /** * Example of the circuit breaker opening on a failing request. @@ -22,9 +22,7 @@ try { method: 'GET', }); } catch (err) { - if (err instanceof HttpClientError) { - if (err.code === HttpClientError.ServerDown) { - console.error('Server unavailable..'); - } + if (err.toString() === 'Error: Breaker is open') { + console.error('Server unavailable..'); } } diff --git a/lib/http-client.js b/lib/http-client.js index 66908ab..72104c2 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -23,6 +23,7 @@ export default class HttpClient { #agent; #breaker; #followRedirects; + // eslint-disable-next-line no-unused-private-class-members #hasFallback = false; #logger; #throwOn400; @@ -84,7 +85,7 @@ export default class HttpClient { } async #request(options = {}) { - if (this.#followRedirects) { + if (this.#followRedirects || options.redirectable) { const { redirect } = interceptors; options.dispatcher = this.#agent.compose( redirect({ maxRedirections: 1 }), @@ -97,7 +98,13 @@ export default class HttpClient { ...options, }); - if (this.#throwOn400 && statusCode >= 400 && statusCode <= 499) { + // 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.trace( @@ -106,9 +113,12 @@ export default class HttpClient { 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.trace( `HTTP ${statusCode} error catched by client. Body: ${errBody}`, @@ -138,20 +148,7 @@ export default class HttpClient { * @returns {Promise} */ async request(options = {}) { - try { - return await this.#breaker.fire(options); - } catch (error) { - if (!this.#hasFallback) { - throw new HttpClientError( - `Error on ${options.method} ${options.origin}${options.path}`, - { - code: error.code, - cause: error, - options, - }, - ); - } - } + return await this.#breaker.fire(options); } /** @@ -169,15 +166,15 @@ export default class HttpClient { /** * Error class for the client */ -export class HttpClientError extends Error { - // Not sure if there is a need for this tbh, but I threw it in there so we can see how it feels. - static ServerDown = 'EOPENBREAKER'; - - constructor(message, { code, cause, options }) { - super(message); - this.name = 'HttpClientError'; - this.code = code; - this.cause = cause; - this.options = options; - } -} +// export class HttpClientError extends Error { +// // Not sure if there is a need for this tbh, but I threw it in there so we can see how it feels. +// static ServerDown = 'EOPENBREAKER'; +// +// constructor(message, { code, cause, options }) { +// super(message); +// this.name = 'HttpClientError'; +// this.code = code; +// this.cause = cause; +// this.options = options; +// } +// } diff --git a/tests/http-client.test.js b/tests/http-client.test.js index fc58792..4e53ff4 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -40,7 +40,7 @@ async function queryUrl({ await client.request({ path, origin: url, method: 'GET' }); } catch (err) { // Push the actual cause here for the tests - if (!suppressErrors) errors.push(err.cause); + if (!suppressErrors) errors.push(err); } } if (errors.length > 0) { @@ -90,8 +90,7 @@ await test('http-client - basics', async (t) => { }); }, { - name: 'HttpClientError', - message: 'Error on GET https://does-not-exist.domain/', + name: 'Error', }, ); await client.close(); @@ -210,7 +209,7 @@ await test('http-client - circuit breaker behaviour', async (t) => { method: 'GET', }); } catch (err) { - if (err.cause.code === 'EOPENBREAKER') { + if (err.toString() === 'Error: Breaker is open') { broken++; } } @@ -222,6 +221,7 @@ await test('http-client - circuit breaker behaviour', async (t) => { ); await afterEach(client); }); + await t.test('can reset breaker', async () => { beforeEach(); const invalidUrl = `http://${host}:3023`; @@ -250,4 +250,7 @@ await test('http-client - circuit breaker behaviour', async (t) => { assert.strictEqual(response.statusCode, 200); await afterEach(client); }); + // await t.test('has a .metrics property', async () => { + // const client = new HttpClient({ threshold: 50, reset: breakerReset }); + // }); }); From bf396618869eeb738c0b589e24fd604deac2f163 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 1 Nov 2024 10:04:34 +0000 Subject: [PATCH 06/32] chore(release): 1.0.0-beta.2 [skip ci] # [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)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0030b6..ffc81a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [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) diff --git a/package.json b/package.json index 124de92..29c4cfd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@podium/http-client", - "version": "1.0.0-beta.1", + "version": "1.0.0-beta.2", "type": "module", "description": "The HTTP client used for all HTTP requests in Podium", "main": "lib/http-client.js", From 3283f5fbe139efeacd1cc67b0a576a3587b14acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Fri, 1 Nov 2024 15:14:51 +0100 Subject: [PATCH 07/32] fix: adding .metrics property and circuit breaker metrics --- README.md | 9 +++ lib/http-client.js | 32 ++++++++++ package.json | 1 + tests/http-client.test.js | 125 ++++++++++++++++++++++++++++++-------- 4 files changed, 140 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index eb02a24..77df909 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,15 @@ For a complete list of options, consult the [undici documentation](https://undic Closes the client and it's connections. +## Metrics + +The expose metrics on the circuit breaking behaviour. + +| name | type | description | +|------------------------------|---------|-------------------------------------------------------------------------------------------| +| `http_client_breaker_events` | counter | Counters on events exposed by the circuit breaker library,
see [opossum] for details | + + [@metrics/metric]: https://github.com/metrics-js/metric '@metrics/metric' [abslog]: https://github.com/trygve-lie/abslog 'abslog' [undici]: https://undici.nodejs.org/ diff --git a/lib/http-client.js b/lib/http-client.js index 72104c2..d473a22 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -2,6 +2,7 @@ import { Agent, request, interceptors } from 'undici'; import createError from 'http-errors'; import Opossum from 'opossum'; import abslog from 'abslog'; +import Metrics from '@metrics/client'; /** * @typedef HttpClientOptions @@ -22,10 +23,12 @@ export default class HttpClient { #abortController; #agent; #breaker; + #breakerCounter; #followRedirects; // eslint-disable-next-line no-unused-private-class-members #hasFallback = false; #logger; + #metrics = new Metrics(); #throwOn400; #throwOn500; @@ -66,6 +69,27 @@ export default class HttpClient { timeout, }); + this.#breakerCounter = this.#metrics.counter({ + name: 'http_client_breaker_events', + description: 'Metrics on breaker events', + }); + for (const eventName of this.#breaker.eventNames()) { + //@ts-ignore + const event = eventName; + //@ts-ignore + this.#breaker.on(event, (e) => { + 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, keepAliveTimeout, @@ -84,6 +108,14 @@ export default class HttpClient { } } + /** + * + * @returns {import('@metrics/client')} + */ + get metrics() { + return this.#metrics; + } + async #request(options = {}) { if (this.#followRedirects || options.redirectable) { const { redirect } = interceptors; diff --git a/package.json b/package.json index 124de92..f0bd7f5 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@podium/typescript-config": "1.0.0", "@types/node": "20.17.3", "@types/opossum": "8.1.8", + "@types/readable-stream": "4.0.15", "concurrently": "9.0.1", "cronometro": "4.0.0", "eslint": "9.13.0", diff --git a/tests/http-client.test.js b/tests/http-client.test.js index 4e53ff4..08eb57a 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -1,4 +1,4 @@ -import test from 'node:test'; +import test, { after, before } from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; @@ -116,13 +116,19 @@ await test('http-client - basics', async (t) => { }); await test('http-client - abort controller', async (t) => { - // Slow responding server to enable us to abort a request - const slowServer = http.createServer(async (request, response) => { - await wait(200); - response.writeHead(200); - response.end(); + let slowServer; + before(async () => { + // Slow responding server to enable us to abort a request + slowServer = http.createServer(async (request, response) => { + await wait(200); + response.writeHead(200); + response.end(); + }); + slowServer.listen(2010, host); + }); + after(async () => { + await slowServer.close(); }); - slowServer.listen(2010, host); await t.test('cancel a request', async () => { const abortController = new AbortController(); let aborted = false; @@ -139,7 +145,6 @@ await test('http-client - abort controller', async (t) => { assert.ok(aborted); await client.close(); }); - // await t.test('auto renew an abort controller', async () => { // const abortController = new AbortController(); // const client = new HttpClient({ timeout: 2000 }); @@ -151,33 +156,39 @@ await test('http-client - abort controller', async (t) => { // }); // await client.close(); // }); - slowServer.close(); }); await test('http-client - redirects', async (t) => { - const to = http.createServer(async (request, response) => { - response.writeHead(200); - response.end(); + let to, from; + after(async function () { + await from.close(); + await to.close(); }); - to.listen(3033, host); - - const from = http.createServer(async (request, response) => { - if (request.url === '/redirect') { - response.setHeader('location', 'http://localhost:3033'); - } - response.writeHead(301); - response.end(); + before(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); }); - from.listen(port, host); await t.test('can follow redirects', async () => { - const client = new HttpClient({ threshold: 50, followRedirects: true }); + const client = new HttpClient({ followRedirects: true }); const response = await client.request({ method: 'GET', origin: `http://${host}:${port}`, path: '/redirect', }); assert.strictEqual(response.statusCode, 200); + await client.close(); }); // await t.test.skip('throw on max redirects', async () => {}); await t.test('does not follow redirects by default', async () => { @@ -188,9 +199,8 @@ await test('http-client - redirects', async (t) => { path: '/redirect', }); assert.strictEqual(response.statusCode, 301); + await client.close(); }); - from.close(); - to.close(); }); await test('http-client - circuit breaker behaviour', async (t) => { @@ -250,7 +260,68 @@ await test('http-client - circuit breaker behaviour', async (t) => { assert.strictEqual(response.statusCode, 200); await afterEach(client); }); - // await t.test('has a .metrics property', async () => { - // const client = new HttpClient({ threshold: 50, reset: breakerReset }); - // }); +}); + +await test('http-client: metrics', async (t) => { + await t.test('has a .metrics property', () => { + const client = new HttpClient(); + assert.notEqual(client.metrics, undefined); + }); + await t.test('metric on open breakers', async () => { + beforeEach(); + const client = new HttpClient({ + threshold: 1, + reset: 10, + timeout: 10000, + throwOn400: false, + throwOn500: false, + }); + const metrics = []; + client.metrics.on('data', (metric) => { + metrics.push(metric); + }); + client.metrics.on('end', () => { + assert.strictEqual(metrics.length, 6, JSON.stringify(metrics)); + assert.strictEqual(metrics[0].name, 'http_client_breaker_events'); + assert.strictEqual(metrics[0].type, 2); + assert.strictEqual(metrics[0].labels[0].value, 'fire'); + assert.strictEqual(metrics[0].labels[1].value, '/not-found'); + + assert.strictEqual(metrics[1].name, 'http_client_breaker_events'); + assert.strictEqual(metrics[1].type, 2); + assert.strictEqual(metrics[1].labels[0].value, 'failure'); + + assert.strictEqual(metrics[2].name, 'http_client_breaker_events'); + assert.strictEqual(metrics[2].type, 2); + assert.strictEqual(metrics[2].labels[0].value, 'open'); + + assert.strictEqual(metrics[3].name, 'http_client_breaker_events'); + assert.strictEqual(metrics[3].type, 2); + assert.strictEqual(metrics[3].labels[0].value, 'fire'); + + assert.strictEqual(metrics[4].name, 'http_client_breaker_events'); + assert.strictEqual(metrics[4].type, 2); + assert.strictEqual(metrics[4].labels[0].value, 'close'); + }); + try { + // Make the circuit open + await client.request({ + path: '/not-found', + origin: 'http://not.found.host:3003', + method: 'GET', + }); + // eslint-disable-next-line no-unused-vars + } catch (_) { + /* empty */ + } + await wait(15); + // Wait for circuit to reset, before using the client again. + await client.request({ + path: '/', + origin: 'http://localhost:3003', + method: 'GET', + }); + client.metrics.push(null); + await afterEach(client); + }); }); From 6e5954bf32dd2b8e8934921b47b4fa2b4e5d98a4 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 4 Nov 2024 12:27:18 +0000 Subject: [PATCH 08/32] chore(release): 1.0.0-beta.3 [skip ci] # [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)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffc81a8..06e3830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [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) diff --git a/package.json b/package.json index 647181e..621cf34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@podium/http-client", - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "type": "module", "description": "The HTTP client used for all HTTP requests in Podium", "main": "lib/http-client.js", From c5ea66b620cb5d5a3fec12b9be0735bb8d0ad6ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Wed, 6 Nov 2024 10:59:43 +0100 Subject: [PATCH 09/32] fix: supporting more request options and metrics on breaker events --- README.md | 19 +++--- lib/http-client.js | 77 ++++++++++------------- tests/http-client.test.js | 128 +++++++++++--------------------------- 3 files changed, 79 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index 77df909..14f66b5 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ const client = new HttpClient(options); | option | default | type | required | details | |---------------------|--------------|------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------| | abortController | `undefined` | `object` | no | See [abortController](#abortController) | +| clientName | `''` | `string` | no | Client name | | connections | `50` | `number` | no | See [connections](#connections) | | fallback | `undefined` | `function` | no | Function to call when requests fail, see [fallback](#fallback) | | followRedirects | `false` | `boolean` | no | Flag for whether to follow redirects or not, see [followRedirects](#followRedirects). | @@ -140,14 +141,16 @@ If the client should throw on http 500 errors. If true, http 500 errors will cou Sends a request using the passed in options object. -| name | type | description | -|---------|-----------------|-------------------------------------------------| -| origin | `string \| URL` | Request origin, ex `https://server.domain:9090` | -| path | `string` | URL path, ex `/foo` | -| method | `string` | HTTP method name | -| headers | `object` | Object with key / value which are strings | -| query | `object` | Object with key / value which are strings | -| signal | `AbortSignal` | Abort signal for canceling requests. | +| 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. | +| 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). diff --git a/lib/http-client.js b/lib/http-client.js index d473a22..dd90a2f 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -6,17 +6,19 @@ import Metrics from '@metrics/client'; /** * @typedef HttpClientOptions - * @property {Number} connections - @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 - @see https://undici.nodejs.org/#/?id=pipelining - * @property {Number} reset - Circuit breaker: How long, in milliseconds, to wait before a tripped circuit should be reset. - * @property {Boolean} throwOn400 - If the client should throw on http 400 errors. If true, http 400 errors will count against tripping the circuit. - * @property {Boolean} throwOn500 - If the client should throw on http 500 errors. If true, http 500 errors will count 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 {AbortController} [abortController] - AbortController instance to use + * @property {string} [clientName] - client name + * @property {Number} [connections=50] - @see https://undici.nodejs.org/#/docs/api/Pool?id=parameter-pooloptions * @property {Function} [fallaback=undefined] - Optional function to call as a fallback when breaker is open. + * @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 {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} [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 {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 HttpClient { @@ -24,10 +26,8 @@ export default class HttpClient { #agent; #breaker; #breakerCounter; - #followRedirects; - // eslint-disable-next-line no-unused-private-class-members - #hasFallback = false; #logger; + #clientName; #metrics = new Metrics(); #throwOn400; #throwOn500; @@ -38,25 +38,25 @@ export default class HttpClient { constructor({ abortController = undefined, autoRenewAbortController = false, + clientName = '', connections = 50, fallback = undefined, - followRedirects = false, keepAliveMaxTimeout = undefined, keepAliveTimeout = undefined, logger = undefined, pipelining = 10, reset = 20000, throwOn400 = false, - throwOn500 = true, + throwOn500 = false, threshold = 25, timeout = 500, } = {}) { this.#logger = abslog(logger); + this.#clientName = clientName; this.#throwOn400 = throwOn400; this.#throwOn500 = throwOn500; this.#abortController = abortController; - this.#followRedirects = followRedirects; // TODO; Can we avoid bind here in a nice way????? this.#breaker = new Opossum(this.#request.bind(this), { @@ -77,17 +77,23 @@ export default class HttpClient { //@ts-ignore const event = eventName; //@ts-ignore - this.#breaker.on(event, (e) => { - 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 }), - }, + if (['open', 'close', 'reject', 'success'].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({ @@ -98,7 +104,6 @@ export default class HttpClient { }); if (fallback) { - this.#hasFallback = true; if (typeof fallback === 'string') { //@ts-ignore this.#fallback(() => fallback); @@ -117,7 +122,7 @@ export default class HttpClient { } async #request(options = {}) { - if (this.#followRedirects || options.redirectable) { + if (options.redirectable) { const { redirect } = interceptors; options.dispatcher = this.#agent.compose( redirect({ maxRedirections: 1 }), @@ -194,19 +199,3 @@ export default class HttpClient { } } } - -/** - * Error class for the client - */ -// export class HttpClientError extends Error { -// // Not sure if there is a need for this tbh, but I threw it in there so we can see how it feels. -// static ServerDown = 'EOPENBREAKER'; -// -// constructor(message, { code, cause, options }) { -// super(message); -// this.name = 'HttpClientError'; -// this.code = code; -// this.cause = cause; -// this.options = options; -// } -// } diff --git a/tests/http-client.test.js b/tests/http-client.test.js index 08eb57a..3dfd913 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -1,5 +1,5 @@ -import test, { after, before } from 'node:test'; -import assert from 'node:assert/strict'; +import { test, before, after, afterEach, beforeEach } from 'node:test'; +import { ok, rejects, notStrictEqual, strictEqual } from 'node:assert/strict'; import http from 'node:http'; import HttpClient from '../lib/http-client.js'; @@ -9,7 +9,7 @@ let httpServer, host = 'localhost', port = 3003; -function beforeEach() { +function startServer() { httpServer = http.createServer(async (request, response) => { response.writeHead(200); response.end(); @@ -22,8 +22,10 @@ function closeServer(s) { s.close(resolve); }); } -async function afterEach(client, server = httpServer) { - await client.close(); +async function stopServer(client, server = httpServer) { + if (client) { + await client.close(); + } await closeServer(server); } @@ -61,7 +63,7 @@ await test('http-client - basics', async (t) => { origin: url, method: 'GET', }); - assert.strictEqual(response.statusCode, 200); + strictEqual(response.statusCode, 200); await client.close(); }); @@ -73,7 +75,7 @@ await test('http-client - basics', async (t) => { origin: url, method: 'GET', }); - assert.strictEqual(response.statusCode, 200); + strictEqual(response.statusCode, 200); await client.close(); await fetch(url); await client.close(); @@ -81,7 +83,7 @@ await test('http-client - basics', async (t) => { await t.test('throws error when no fallback provided', async () => { const client = new HttpClient(); - assert.rejects( + rejects( async () => { await client.request({ path: '/', @@ -108,7 +110,7 @@ await test('http-client - basics', async (t) => { origin: 'https://does-not-exist.domain', method: 'GET', }); - assert.strictEqual(isCaught, true); + strictEqual(isCaught, true); await client.close(); }); @@ -117,7 +119,7 @@ await test('http-client - basics', async (t) => { await test('http-client - abort controller', async (t) => { let slowServer; - before(async () => { + beforeEach(async function () { // Slow responding server to enable us to abort a request slowServer = http.createServer(async (request, response) => { await wait(200); @@ -126,8 +128,8 @@ await test('http-client - abort controller', async (t) => { }); slowServer.listen(2010, host); }); - after(async () => { - await slowServer.close(); + afterEach(function () { + slowServer.close(); }); await t.test('cancel a request', async () => { const abortController = new AbortController(); @@ -142,9 +144,10 @@ await test('http-client - abort controller', async (t) => { origin: 'http://localhost:2010', method: 'GET', }); - assert.ok(aborted); + strictEqual(aborted, true); await client.close(); }); + // await t.test('auto renew an abort controller', async () => { // const abortController = new AbortController(); // const client = new HttpClient({ timeout: 2000 }); @@ -160,11 +163,7 @@ await test('http-client - abort controller', async (t) => { await test('http-client - redirects', async (t) => { let to, from; - after(async function () { - await from.close(); - await to.close(); - }); - before(function () { + before(async function () { to = http.createServer(async (request, response) => { response.writeHead(200); response.end(); @@ -179,16 +178,20 @@ await test('http-client - redirects', async (t) => { }); from.listen(port, host); }); + after(function () { + from.close(); + to.close(); + }); - await t.test('can follow redirects', async () => { - const client = new HttpClient({ followRedirects: true }); + 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, }); - assert.strictEqual(response.statusCode, 200); - 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 () => { @@ -198,15 +201,14 @@ await test('http-client - redirects', async (t) => { origin: `http://${host}:${port}`, path: '/redirect', }); - assert.strictEqual(response.statusCode, 301); - await client.close(); + strictEqual(response.statusCode, 301); }); }); await test('http-client - circuit breaker behaviour', async (t) => { const url = `http://${host}:${port}`; + beforeEach(startServer); await t.test('opens on failure threshold', async () => { - beforeEach(); const invalidUrl = `http://${host}:3013`; const client = new HttpClient({ threshold: 50 }); @@ -224,16 +226,15 @@ await test('http-client - circuit breaker behaviour', async (t) => { } } } - assert.strictEqual( + strictEqual( broken, 4, `breaker open on 4 out of 5 requests, was ${broken}`, ); - await afterEach(client); + await stopServer(client); }); await t.test('can reset breaker', async () => { - beforeEach(); const invalidUrl = `http://${host}:3023`; const breakerReset = 10; const client = new HttpClient({ threshold: 50, reset: breakerReset }); @@ -250,78 +251,19 @@ await test('http-client - circuit breaker behaviour', async (t) => { isOpen = true; } } - assert.strictEqual(isOpen, true, `breaker opened, ${isOpen}`); + 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); - await afterEach(client); + strictEqual(response.statusCode, 200); + await stopServer(client); }); -}); - -await test('http-client: metrics', async (t) => { - await t.test('has a .metrics property', () => { + await t.test('exposed breaker metrics', async () => { const client = new HttpClient(); - assert.notEqual(client.metrics, undefined); - }); - await t.test('metric on open breakers', async () => { - beforeEach(); - const client = new HttpClient({ - threshold: 1, - reset: 10, - timeout: 10000, - throwOn400: false, - throwOn500: false, - }); - const metrics = []; - client.metrics.on('data', (metric) => { - metrics.push(metric); - }); - client.metrics.on('end', () => { - assert.strictEqual(metrics.length, 6, JSON.stringify(metrics)); - assert.strictEqual(metrics[0].name, 'http_client_breaker_events'); - assert.strictEqual(metrics[0].type, 2); - assert.strictEqual(metrics[0].labels[0].value, 'fire'); - assert.strictEqual(metrics[0].labels[1].value, '/not-found'); - - assert.strictEqual(metrics[1].name, 'http_client_breaker_events'); - assert.strictEqual(metrics[1].type, 2); - assert.strictEqual(metrics[1].labels[0].value, 'failure'); - - assert.strictEqual(metrics[2].name, 'http_client_breaker_events'); - assert.strictEqual(metrics[2].type, 2); - assert.strictEqual(metrics[2].labels[0].value, 'open'); - - assert.strictEqual(metrics[3].name, 'http_client_breaker_events'); - assert.strictEqual(metrics[3].type, 2); - assert.strictEqual(metrics[3].labels[0].value, 'fire'); - - assert.strictEqual(metrics[4].name, 'http_client_breaker_events'); - assert.strictEqual(metrics[4].type, 2); - assert.strictEqual(metrics[4].labels[0].value, 'close'); - }); - try { - // Make the circuit open - await client.request({ - path: '/not-found', - origin: 'http://not.found.host:3003', - method: 'GET', - }); - // eslint-disable-next-line no-unused-vars - } catch (_) { - /* empty */ - } - await wait(15); - // Wait for circuit to reset, before using the client again. - await client.request({ - path: '/', - origin: 'http://localhost:3003', - method: 'GET', - }); - client.metrics.push(null); - await afterEach(client); + notStrictEqual(client.metrics, undefined, 'has a .metrics property'); + await stopServer(client); }); }); From 1a62a07de4c070738cebdd9f02a9e73c7fab784f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Wed, 6 Nov 2024 10:59:43 +0100 Subject: [PATCH 10/32] fix: supporting more request options and metrics on breaker events --- tests/http-client.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http-client.test.js b/tests/http-client.test.js index 3dfd913..ea1a047 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -1,5 +1,5 @@ import { test, before, after, afterEach, beforeEach } from 'node:test'; -import { ok, rejects, notStrictEqual, strictEqual } from 'node:assert/strict'; +import { rejects, notStrictEqual, strictEqual } from 'node:assert/strict'; import http from 'node:http'; import HttpClient from '../lib/http-client.js'; From 43efba553dadfca6501cc90c83ba69cf293bad18 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 6 Nov 2024 10:26:41 +0000 Subject: [PATCH 11/32] chore(release): 1.0.0-beta.4 [skip ci] # [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)) --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e3830..f503588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# [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) diff --git a/package.json b/package.json index 621cf34..e8b519b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@podium/http-client", - "version": "1.0.0-beta.3", + "version": "1.0.0-beta.4", "type": "module", "description": "The HTTP client used for all HTTP requests in Podium", "main": "lib/http-client.js", From 5820d35fb0be8afd4c4886997ec07f69ebf0b5b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Thu, 7 Nov 2024 15:11:07 +0100 Subject: [PATCH 12/32] fix: adjusting abort controller implementation --- README.md | 194 ++++++++++++++++++++++++++++++++++++-- lib/http-client.js | 25 ++--- tests/http-client.test.js | 70 +++++++++----- 3 files changed, 248 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 14f66b5..0dbdfc7 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,22 @@ if (response.ok) { } ``` +### Aborting requests + +```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(); +``` + ## API ### Constructor @@ -41,7 +57,6 @@ const client = new HttpClient(options); | option | default | type | required | details | |---------------------|--------------|------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------| -| abortController | `undefined` | `object` | no | See [abortController](#abortController) | | clientName | `''` | `string` | no | Client name | | connections | `50` | `number` | no | See [connections](#connections) | | fallback | `undefined` | `function` | no | Function to call when requests fail, see [fallback](#fallback) | @@ -57,9 +72,91 @@ const client = new HttpClient(options); | timeout | `500` | `number` | no | Circuit breaker: How long, in milliseconds, a request can maximum take. Requests exceeding this limit counts against tripping the circuit. | -##### abortController +##### connections + +Property is sent to the underlying http library. +See library docs on [connections](https://undici.nodejs.org/#/docs/api/Pool?id=parameter-pooloptions) + +##### fallback + +Optional function to run when a request fails. + +```js +// TBA +``` + +##### followRe# @podium/http-client + +⚠️ This project is still work in progress, should not be used for anything just yet. + +Generic http client built on [undici] with a circuit breaker using [opossum], error handling and metrics out of the box. + +[![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) + +## Installation + +*Note!* Requires Node.js v20 or later. + +```bash +npm install @podium/http-client +``` + +## Usage + +```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) { + // +} +``` + +### Aborting requests + +```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(); +``` + +## API + +### Constructor + +```js +import client from '@podium/http-client'; + +const client = new HttpClient(options); +``` + +#### options + +| option | default | type | required | details | +|---------------------|--------------|------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------| +| clientName | `''` | `string` | no | Client name | +| connections | `50` | `number` | no | See [connections](#connections) | +| fallback | `undefined` | `function` | no | Function to call when requests fail, see [fallback](#fallback) | +| 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. | -Passing in an [AbortController](https://nodejs.org/docs/latest/api/globals.html#globals_class_abortcontroller) enables aborting requests. ##### connections @@ -74,7 +171,93 @@ Optional function to run when a request fails. // TBA ``` -##### followRedirects +##### 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 +const layout = new Layout({ + name: 'myLayout', + pathname: '/foo', + logger: console, +}); +``` + +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. | +| 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 it's connections. + +## Metrics + +The expose metrics on the circuit breaking behaviour. + +| name | type | description | +|------------------------------|---------|-------------------------------------------------------------------------------------------| +| `http_client_breaker_events` | counter | Counters on events exposed by the circuit breaker library,
see [opossum] for details | + + +[@metrics/metric]: https://github.com/metrics-js/metric '@metrics/metric' +[abslog]: https://github.com/trygve-lie/abslog 'abslog' +[undici]: https://undici.nodejs.org/ +[opossum]: https://github.com/nodeshift/opossum/ +directs TODO!!! decide what to do with the redirects stuff... @@ -133,8 +316,6 @@ If the client should throw on http 400 errors. If true, http 400 errors will cou ##### 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 = {}) @@ -154,7 +335,6 @@ Sends a request using the passed in options object. For a complete list of options, consult the [undici documentation](https://undici.nodejs.org/#/?id=undicirequesturl-options-promise). - ### async close() Closes the client and it's connections. diff --git a/lib/http-client.js b/lib/http-client.js index dd90a2f..5ea1ca1 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -5,8 +5,12 @@ import abslog from 'abslog'; import Metrics from '@metrics/client'; /** + * @typedef HttpClientRequestOptionsAdditions + * @property {AbortSignal} [signal] + * + * @typedef {import('undici').Dispatcher.DispatchOptions & HttpClientRequestOptionsAdditions} HttpClientRequestOptions + * * @typedef HttpClientOptions - * @property {AbortController} [abortController] - AbortController instance to use * @property {string} [clientName] - client name * @property {Number} [connections=50] - @see https://undici.nodejs.org/#/docs/api/Pool?id=parameter-pooloptions * @property {Function} [fallaback=undefined] - Optional function to call as a fallback when breaker is open. @@ -15,14 +19,13 @@ import Metrics from '@metrics/client'; * @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} [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 {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 HttpClient { - #abortController; #agent; #breaker; #breakerCounter; @@ -36,8 +39,6 @@ export default class HttpClient { * @property {HttpClientOptions} options - options */ constructor({ - abortController = undefined, - autoRenewAbortController = false, clientName = '', connections = 50, fallback = undefined, @@ -56,14 +57,8 @@ export default class HttpClient { this.#throwOn400 = throwOn400; this.#throwOn500 = throwOn500; - this.#abortController = abortController; - // TODO; Can we avoid bind here in a nice way????? this.#breaker = new Opossum(this.#request.bind(this), { - ...(this.#abortController && { - abortController: this.#abortController, - }), - ...(autoRenewAbortController && { autoRenewAbortController }), errorThresholdPercentage: threshold, resetTimeout: reset, timeout, @@ -73,6 +68,7 @@ export default class HttpClient { name: 'http_client_breaker_events', description: 'Metrics on breaker events', }); + // TODO add a link to the Opossum events for (const eventName of this.#breaker.eventNames()) { //@ts-ignore const event = eventName; @@ -121,6 +117,7 @@ export default class HttpClient { return this.#metrics; } + // TODO Adda a type definition for this async #request(options = {}) { if (options.redirectable) { const { redirect } = interceptors; @@ -131,6 +128,8 @@ export default class HttpClient { options.dispatcher = this.#agent; } + // TODO add histogram metrics on each request + // Labels: url, query params, options.... const { statusCode, headers, trailers, body } = await request({ ...options, }); @@ -142,6 +141,7 @@ export default class HttpClient { statusCode >= 400 && statusCode <= 499 ) { + // TODO add count metrics // Body must be consumed; https://github.com/nodejs/undici/issues/583#issuecomment-855384858 const errBody = await body.text(); this.#logger.trace( @@ -155,6 +155,7 @@ export default class HttpClient { statusCode >= 500 && statusCode <= 599 ) { + // TODO add count metrics // Body must be consumed; https://github.com/nodejs/undici/issues/583#issuecomment-855384858 const errBody = await body.text(); this.#logger.trace( @@ -181,7 +182,7 @@ export default class HttpClient { /** * Requests a URL. - * @param {any} [options] + * @param {HttpClientRequestOptions | Object} [options] * @returns {Promise} */ async request(options = {}) { diff --git a/tests/http-client.test.js b/tests/http-client.test.js index ea1a047..d290a3e 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -1,5 +1,5 @@ import { test, before, after, afterEach, beforeEach } from 'node:test'; -import { rejects, notStrictEqual, strictEqual } from 'node:assert/strict'; +import { rejects, notStrictEqual, ok, strictEqual } from 'node:assert/strict'; import http from 'node:http'; import HttpClient from '../lib/http-client.js'; @@ -117,35 +117,61 @@ await test('http-client - basics', async (t) => { await closeServer(server); }); -await test('http-client - abort controller', async (t) => { +await test('http-client - timeouts', async (t) => { let slowServer; - beforeEach(async function () { + async function b() { // Slow responding server to enable us to abort a request slowServer = http.createServer(async (request, response) => { - await wait(200); + await wait(3000); response.writeHead(200); response.end(); }); slowServer.listen(2010, host); - }); - afterEach(function () { - slowServer.close(); - }); - await t.test('cancel a request', async () => { + } + 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(); - let aborted = false; - setTimeout(() => { - abortController.abort(); - aborted = true; - }, 100); - const client = new HttpClient({ timeout: 2000 }); - await client.request({ - path: '/', - origin: 'http://localhost:2010', - method: 'GET', - }); - strictEqual(aborted, true); - await client.close(); + 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 () => { From 16b65b513a1d6932316b781c4e675e0956a76691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Thu, 7 Nov 2024 15:41:58 +0100 Subject: [PATCH 13/32] fix: adding back breaker metrics tests --- lib/http-client.js | 9 ++++ tests/http-client.test.js | 98 ++++++++++++++++++++++++++++++--------- 2 files changed, 84 insertions(+), 23 deletions(-) diff --git a/lib/http-client.js b/lib/http-client.js index 5ea1ca1..869754c 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -32,6 +32,7 @@ export default class HttpClient { #logger; #clientName; #metrics = new Metrics(); + #requestDuration; #throwOn400; #throwOn500; @@ -64,6 +65,12 @@ export default class HttpClient { timeout, }); + this.#requestDuration = this.#metrics.histogram({ + name: 'http_client_request_duration', + description: 'Request duration', + labels: { code: 0 }, + 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', @@ -128,12 +135,14 @@ export default class HttpClient { options.dispatcher = this.#agent; } + const stopRequestTimer = this.#requestDuration.timer(); // TODO add histogram metrics on each request // Labels: url, query params, options.... const { statusCode, headers, trailers, body } = await request({ ...options, }); + stopRequestTimer(); // 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 ( diff --git a/tests/http-client.test.js b/tests/http-client.test.js index d290a3e..38e8031 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -1,5 +1,5 @@ -import { test, before, after, afterEach, beforeEach } from 'node:test'; -import { rejects, notStrictEqual, ok, strictEqual } from 'node:assert/strict'; +import { test, before, after, beforeEach } from 'node:test'; +import { rejects, notStrictEqual, strictEqual } from 'node:assert/strict'; import http from 'node:http'; import HttpClient from '../lib/http-client.js'; @@ -8,6 +8,7 @@ import { wait } from './utilities.js'; let httpServer, host = 'localhost', port = 3003; +const url = `http://${host}:${port}`; function startServer() { httpServer = http.createServer(async (request, response) => { @@ -118,24 +119,24 @@ await test('http-client - basics', async (t) => { }); 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); - } - } + // 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(); @@ -232,7 +233,6 @@ await test('http-client - redirects', async (t) => { }); await test('http-client - circuit breaker behaviour', async (t) => { - const url = `http://${host}:${port}`; beforeEach(startServer); await t.test('opens on failure threshold', async () => { const invalidUrl = `http://${host}:3013`; @@ -287,9 +287,61 @@ await test('http-client - circuit breaker behaviour', async (t) => { strictEqual(response.statusCode, 200); await stopServer(client); }); - await t.test('exposed breaker metrics', async () => { +}); + +await test('http-client: metrics', async (t) => { + await t.test('has a .metrics property', () => { const client = new HttpClient(); - notStrictEqual(client.metrics, undefined, 'has a .metrics property'); + notStrictEqual(client.metrics, undefined); + }); + await t.test('metric on open breakers', async () => { + await startServer(); + const client = new HttpClient({ + threshold: 1, + reset: 10, + timeout: 10000, + throwOn400: false, + throwOn500: false, + }); + const metrics = []; + client.metrics.on('data', (metric) => { + metrics.push(metric); + }); + client.metrics.on('end', () => { + strictEqual(metrics.length, 4, JSON.stringify(metrics)); + + strictEqual(metrics[0].name, 'http_client_breaker_events'); + strictEqual(metrics[0].type, 2); + strictEqual(metrics[0].labels[0].value, 'open'); + + strictEqual(metrics[2].name, 'http_client_breaker_events'); + strictEqual(metrics[2].type, 2); + strictEqual(metrics[2].labels[0].value, 'close'); + + strictEqual(metrics[3].name, 'http_client_breaker_events'); + strictEqual(metrics[3].type, 2); + strictEqual(metrics[3].labels[0].value, 'success'); + }); + try { + // Make the circuit open + await client.request({ + path: '/not-found', + origin: 'http://not.found.host:3003', + method: 'GET', + throwable: false, + }); + // 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', + }); + client.metrics.push(null); await stopServer(client); }); }); From c82e580797218c842ac41b81c395b15e6576f202 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 7 Nov 2024 14:42:45 +0000 Subject: [PATCH 14/32] chore(release): 1.0.0-beta.5 [skip ci] # [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)) --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f503588..a3c8302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# [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) diff --git a/package.json b/package.json index e8b519b..bd1cea3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@podium/http-client", - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "type": "module", "description": "The HTTP client used for all HTTP requests in Podium", "main": "lib/http-client.js", From 201b92f0680da206be4f2fb3728c8e49681d8759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Fri, 8 Nov 2024 10:46:52 +0100 Subject: [PATCH 15/32] adding metrics on the requests --- README.md | 8 +++-- lib/http-client.js | 32 +++++++++++++++----- tests/http-client.test.js | 63 ++++++++++++++++++++++++++++++++++----- 3 files changed, 85 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0dbdfc7..a551d94 100644 --- a/README.md +++ b/README.md @@ -248,9 +248,11 @@ Closes the client and it's connections. The expose metrics on the circuit breaking behaviour. -| name | type | description | -|------------------------------|---------|-------------------------------------------------------------------------------------------| -| `http_client_breaker_events` | counter | Counters on events exposed by the circuit breaker library,
see [opossum] for details | +| name | type | description | +|--------------------------------|-----------|-------------------------------------------------------------------------------------------| +| `http_client_breaker_events` | counter | Counters on events exposed by the circuit breaker library,
see [opossum] for details | +| `http_client_request_duration` | histogram | Request duration. | +| `http_client_request_errors` | histogram | HTTP status codes higher than 399. | [@metrics/metric]: https://github.com/metrics-js/metric '@metrics/metric' diff --git a/lib/http-client.js b/lib/http-client.js index 869754c..ed0cfa8 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -32,6 +32,7 @@ export default class HttpClient { #logger; #clientName; #metrics = new Metrics(); + #errorCounter; #requestDuration; #throwOn400; #throwOn500; @@ -65,17 +66,21 @@ export default class HttpClient { 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: { code: 0 }, 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', }); - // TODO add a link to the Opossum 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; @@ -124,7 +129,6 @@ export default class HttpClient { return this.#metrics; } - // TODO Adda a type definition for this async #request(options = {}) { if (options.redirectable) { const { redirect } = interceptors; @@ -136,13 +140,27 @@ export default class HttpClient { } const stopRequestTimer = this.#requestDuration.timer(); - // TODO add histogram metrics on each request - // Labels: url, query params, options.... const { statusCode, headers, trailers, body } = await request({ ...options, }); - stopRequestTimer(); + stopRequestTimer({ + labels: { + method: options.method, + status: statusCode, + url: options.url, + }, + }); + + 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 ( @@ -150,7 +168,6 @@ export default class HttpClient { statusCode >= 400 && statusCode <= 499 ) { - // TODO add count metrics // Body must be consumed; https://github.com/nodejs/undici/issues/583#issuecomment-855384858 const errBody = await body.text(); this.#logger.trace( @@ -164,7 +181,6 @@ export default class HttpClient { statusCode >= 500 && statusCode <= 599 ) { - // TODO add count metrics // Body must be consumed; https://github.com/nodejs/undici/issues/583#issuecomment-855384858 const errBody = await body.text(); this.#logger.trace( diff --git a/tests/http-client.test.js b/tests/http-client.test.js index 38e8031..9531f7e 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -1,5 +1,5 @@ import { test, before, after, beforeEach } from 'node:test'; -import { rejects, notStrictEqual, strictEqual } from 'node:assert/strict'; +import { rejects, notStrictEqual, ok, strictEqual } from 'node:assert/strict'; import http from 'node:http'; import HttpClient from '../lib/http-client.js'; @@ -12,7 +12,11 @@ const url = `http://${host}:${port}`; function startServer() { 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); @@ -294,12 +298,59 @@ await test('http-client: metrics', async (t) => { const client = new HttpClient(); notStrictEqual(client.metrics, undefined); }); - await t.test('metric on open breakers', async () => { + await t.test('request 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', () => { + 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); + 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.request({ + path: '/', + origin: url, + method: 'GET', + }); + + await client.request({ + path: '/not-found', + origin: url, + method: 'GET', + }); + client.metrics.push(null); + await stopServer(client); + }); + await t.test('breaker metrics', async () => { await startServer(); const client = new HttpClient({ - threshold: 1, reset: 10, - timeout: 10000, + timeout: 1000, throwOn400: false, throwOn500: false, }); @@ -327,8 +378,6 @@ await test('http-client: metrics', async (t) => { await client.request({ path: '/not-found', origin: 'http://not.found.host:3003', - method: 'GET', - throwable: false, }); // eslint-disable-next-line no-unused-vars } catch (_) { From 726e6ee7bc7930cf962334a7a4567170a7424fc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Fri, 8 Nov 2024 11:05:01 +0100 Subject: [PATCH 16/32] docs update --- README.md | 191 +++------------------------------------------- examples/abort.js | 27 +++++++ 2 files changed, 39 insertions(+), 179 deletions(-) create mode 100644 examples/abort.js diff --git a/README.md b/README.md index a551d94..1173e12 100644 --- a/README.md +++ b/README.md @@ -7,92 +7,12 @@ Generic http client built on [undici] with a circuit breaker using [opossum], er [![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) -## Installation - -*Note!* Requires Node.js v20 or later. - -```bash -npm install @podium/http-client -``` - -## Usage - -```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) { - // -} -``` - -### Aborting requests - -```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(); -``` - -## API - -### Constructor - -```js -import client from '@podium/http-client'; - -const client = new HttpClient(options); -``` - -#### options - -| option | default | type | required | details | -|---------------------|--------------|------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------| -| clientName | `''` | `string` | no | Client name | -| connections | `50` | `number` | no | See [connections](#connections) | -| fallback | `undefined` | `function` | no | Function to call when requests fail, see [fallback](#fallback) | -| followRedirects | `false` | `boolean` | no | Flag for whether to follow redirects or not, see [followRedirects](#followRedirects). | -| 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) - -##### fallback - -Optional function to run when a request fails. - -```js -// TBA -``` - -##### followRe# @podium/http-client - -⚠️ This project is still work in progress, should not be used for anything just yet. - -Generic http client built on [undici] with a circuit breaker using [opossum], error handling and metrics out of the box. - -[![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) +## Documentation + - [Installing](#installation) + - [Usage](#usage) + - [API](#api) + - [Methods](#methods) + - [Metrics](#metrics) ## Installation @@ -147,6 +67,7 @@ const client = new HttpClient(options); | clientName | `''` | `string` | no | Client name | | connections | `50` | `number` | no | See [connections](#connections) | | fallback | `undefined` | `function` | no | Function to call when requests fail, see [fallback](#fallback) | +| followRedirects | `false` | `boolean` | no | Flag for whether to follow redirects or not, see [followRedirects](#followRedirects). | | 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 | @@ -171,95 +92,7 @@ Optional function to run when a request fails. // TBA ``` -##### 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 -const layout = new Layout({ - name: 'myLayout', - pathname: '/foo', - logger: console, -}); -``` - -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. | -| 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 it's connections. - -## Metrics - -The expose metrics on the circuit breaking behaviour. - -| name | type | description | -|--------------------------------|-----------|-------------------------------------------------------------------------------------------| -| `http_client_breaker_events` | counter | Counters on events exposed by the circuit breaker library,
see [opossum] for details | -| `http_client_request_duration` | histogram | Request duration. | -| `http_client_request_errors` | histogram | HTTP status codes higher than 399. | - - -[@metrics/metric]: https://github.com/metrics-js/metric '@metrics/metric' -[abslog]: https://github.com/trygve-lie/abslog 'abslog' -[undici]: https://undici.nodejs.org/ -[opossum]: https://github.com/nodeshift/opossum/ -directs +##### followRedirects TODO!!! decide what to do with the redirects stuff... @@ -318,9 +151,9 @@ If the client should throw on http 400 errors. If true, http 400 errors will cou ##### throwOn500 If the client should throw on http 500 errors. If true, http 500 errors will count against tripping the circuit. -## Methods +### Methods -### async request(options = {}) +#### async request(options = {}) Sends a request using the passed in options object. @@ -337,11 +170,11 @@ Sends a request using the passed in options object. For a complete list of options, consult the [undici documentation](https://undici.nodejs.org/#/?id=undicirequesturl-options-promise). -### async close() +#### async close() Closes the client and it's connections. -## Metrics +### Metrics The expose metrics on the circuit breaking behaviour. 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(); From efe65e7de5c3d4b3e11cb0f46a5adf9493cad51b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Fri, 8 Nov 2024 15:00:02 +0100 Subject: [PATCH 17/32] fix: updating metrics to collect better things --- README.md | 3 ++- lib/http-client.js | 12 +++++++++++- tests/http-client.test.js | 7 ++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1173e12..8d81f57 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ Generic http client built on [undici] with a circuit breaker using [opossum], er [![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) -## Documentation +**Table of contents:** + - [Installing](#installation) - [Usage](#usage) - [API](#api) diff --git a/lib/http-client.js b/lib/http-client.js index ed0cfa8..b7c1111 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -73,6 +73,11 @@ export default class HttpClient { 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({ @@ -129,6 +134,11 @@ export default class HttpClient { return this.#metrics; } + /** + * + * @param {HttpClientRequestOptionsAdditions | Object} options + * @returns {Promise<{headers: IncomingHttpHeaders, body: BodyReadable & Dispatcher.BodyMixin, statusCode: number, trailers: Record}>} + */ async #request(options = {}) { if (options.redirectable) { const { redirect } = interceptors; @@ -148,7 +158,7 @@ export default class HttpClient { labels: { method: options.method, status: statusCode, - url: options.url, + url: `${options.origin}${options.path}`, }, }); diff --git a/tests/http-client.test.js b/tests/http-client.test.js index 9531f7e..5d8e992 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -226,7 +226,7 @@ await test('http-client - redirects', async (t) => { }); // await t.test.skip('throw on max redirects', async () => {}); await t.test('does not follow redirects by default', async () => { - const client = new HttpClient({ threshold: 50 }); + const client = new HttpClient(); const response = await client.request({ method: 'GET', origin: `http://${host}:${port}`, @@ -323,6 +323,11 @@ await test('http-client: metrics', async (t) => { 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); From dbe5402aa56ac44e1929c8e693fc53900089d04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Mon, 11 Nov 2024 12:49:01 +0100 Subject: [PATCH 18/32] chore: fixing intablity in test --- tests/http-client.test.js | 62 +++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/tests/http-client.test.js b/tests/http-client.test.js index 5d8e992..356af3b 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -1,5 +1,5 @@ -import { test, before, after, beforeEach } from 'node:test'; -import { rejects, notStrictEqual, ok, strictEqual } 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 HttpClient from '../lib/http-client.js'; @@ -11,15 +11,17 @@ let httpServer, const url = `http://${host}:${port}`; function startServer() { - httpServer = http.createServer(async (request, response) => { - if (request.url === '/not-found') { - response.writeHead(404); - } else { - response.writeHead(200); - } - response.end(); + return new Promise((resolve) => { + httpServer = http.createServer(async (request, response) => { + if (request.url === '/not-found') { + response.writeHead(404); + } else { + response.writeHead(200); + } + response.end(); + }); + httpServer.listen(port, host, resolve); }); - httpServer.listen(port, host); } function closeServer(s) { @@ -27,6 +29,7 @@ function closeServer(s) { s.close(resolve); }); } + async function stopServer(client, server = httpServer) { if (client) { await client.close(); @@ -54,6 +57,7 @@ async function queryUrl({ throw new Error(errors.toString()); } } + await test('http-client - basics', async (t) => { const url = `http://${host}:2001`; const server = http.createServer(async (request, response) => { @@ -222,6 +226,7 @@ await test('http-client - redirects', async (t) => { path: '/redirect', redirectable: true, }); + await client.close(); strictEqual(response.statusCode, 200); }); // await t.test.skip('throw on max redirects', async () => {}); @@ -233,6 +238,7 @@ await test('http-client - redirects', async (t) => { path: '/redirect', }); strictEqual(response.statusCode, 301); + await client.close(); }); }); @@ -261,6 +267,7 @@ await test('http-client - circuit breaker behaviour', async (t) => { 4, `breaker open on 4 out of 5 requests, was ${broken}`, ); + await client.close(); await stopServer(client); }); @@ -289,6 +296,7 @@ await test('http-client - circuit breaker behaviour', async (t) => { method: 'GET', }); strictEqual(response.statusCode, 200); + await client.close(); await stopServer(client); }); }); @@ -300,16 +308,23 @@ await test('http-client: metrics', async (t) => { }); await t.test('request metrics', async () => { await startServer(); - const client = new HttpClient({ - reset: 10, - timeout: 1000, - throwOn400: false, - throwOn500: false, - }); + 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', + }); + await client.request({ + path: '/not-found', + origin: url, + method: 'GET', + }); + client.metrics.push(null); client.metrics.on('end', () => { const requestMetrics = metrics.filter( (m) => @@ -337,20 +352,10 @@ await test('http-client: metrics', async (t) => { strictEqual(requestMetrics[2].labels[1].name, 'status'); strictEqual(requestMetrics[2].labels[1].value, 404); }); - await client.request({ - path: '/', - origin: url, - method: 'GET', - }); - - await client.request({ - path: '/not-found', - origin: url, - method: 'GET', - }); - client.metrics.push(null); + await client.close(); await stopServer(client); }); + await t.test('breaker metrics', async () => { await startServer(); const client = new HttpClient({ @@ -396,6 +401,7 @@ await test('http-client: metrics', async (t) => { method: 'GET', }); client.metrics.push(null); + await client.close(); await stopServer(client); }); }); From ede638ba9bd726663a2249cfff3039297083f5e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Mon, 11 Nov 2024 13:38:14 +0100 Subject: [PATCH 19/32] fixing types --- lib/http-client.js | 7 ++++--- tests/http-client.test.js | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/http-client.js b/lib/http-client.js index b7c1111..cfa21f0 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -1,4 +1,4 @@ -import { Agent, request, interceptors } from 'undici'; +import { Agent, interceptors, request } from 'undici'; import createError from 'http-errors'; import Opossum from 'opossum'; import abslog from 'abslog'; @@ -8,6 +8,8 @@ import Metrics from '@metrics/client'; * @typedef HttpClientRequestOptionsAdditions * @property {AbortSignal} [signal] * + * @typedef {Pick} HttpClientResponse + * * @typedef {import('undici').Dispatcher.DispatchOptions & HttpClientRequestOptionsAdditions} HttpClientRequestOptions * * @typedef HttpClientOptions @@ -135,9 +137,8 @@ export default class HttpClient { } /** - * * @param {HttpClientRequestOptionsAdditions | Object} options - * @returns {Promise<{headers: IncomingHttpHeaders, body: BodyReadable & Dispatcher.BodyMixin, statusCode: number, trailers: Record}>} + * @returns {Promise} */ async #request(options = {}) { if (options.redirectable) { diff --git a/tests/http-client.test.js b/tests/http-client.test.js index 356af3b..f502e33 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -20,7 +20,9 @@ function startServer() { } response.end(); }); - httpServer.listen(port, host, resolve); + httpServer.listen(port, host, () => { + resolve(); + }); }); } From 920867c980be038a9c880d5a2a08732be8e8196f Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 11 Nov 2024 12:38:58 +0000 Subject: [PATCH 20/32] chore(release): 1.0.0-beta.6 [skip ci] # [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)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c8302..ba272ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [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) diff --git a/package.json b/package.json index bd1cea3..2c1bd77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@podium/http-client", - "version": "1.0.0-beta.5", + "version": "1.0.0-beta.6", "type": "module", "description": "The HTTP client used for all HTTP requests in Podium", "main": "lib/http-client.js", From 97180e3fb8b135fcd0cc4810148701f48b9db1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Tue, 12 Nov 2024 10:24:54 +0100 Subject: [PATCH 21/32] fix: making .request return the entire response --- lib/http-client.js | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/http-client.js b/lib/http-client.js index cfa21f0..9e0f4a6 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -8,7 +8,7 @@ import Metrics from '@metrics/client'; * @typedef HttpClientRequestOptionsAdditions * @property {AbortSignal} [signal] * - * @typedef {Pick} HttpClientResponse + * @typedef {import('undici').Dispatcher.ResponseData} HttpClientResponse * * @typedef {import('undici').Dispatcher.DispatchOptions & HttpClientRequestOptionsAdditions} HttpClientRequestOptions * @@ -27,9 +27,13 @@ import Metrics from '@metrics/client'; * @property {Number} [timeout=500] - Circuit breaker: How long, in milliseconds, a request can maximum take. Requests exceeding this limit counts against tripping the circuit. **/ +/** + * 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; - #breaker; + /** @type {import('opossum')} */ #breaker; #breakerCounter; #logger; #clientName; @@ -61,7 +65,6 @@ export default class HttpClient { 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, resetTimeout: reset, @@ -151,9 +154,10 @@ export default class HttpClient { } const stopRequestTimer = this.#requestDuration.timer(); - const { statusCode, headers, trailers, body } = await request({ + const response = await request({ ...options, }); + const { statusCode, body } = response; stopRequestTimer({ labels: { @@ -200,12 +204,7 @@ export default class HttpClient { throw createError(statusCode); } - return { - statusCode, - headers, - trailers, - body, - }; + return response; } /** From 2afb5a586c573121116c4690a7b7f68614b69ddb Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 12 Nov 2024 09:28:17 +0000 Subject: [PATCH 22/32] chore(release): 1.0.0-beta.7 [skip ci] # [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)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba272ac..a1b76c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [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) diff --git a/package.json b/package.json index 2c1bd77..ed51d3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@podium/http-client", - "version": "1.0.0-beta.6", + "version": "1.0.0-beta.7", "type": "module", "description": "The HTTP client used for all HTTP requests in Podium", "main": "lib/http-client.js", From b60865ecd60901b27db8610422cf59ff52c8660e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Tue, 12 Nov 2024 11:13:09 +0100 Subject: [PATCH 23/32] removing fallback option --- README.md | 71 ++++++++++++++++++++-------------------------- lib/http-client.js | 23 +++++++-------- 2 files changed, 41 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 8d81f57..37ef44e 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ Generic http client built on [undici] with a circuit breaker using [opossum], er - [Installing](#installation) - [Usage](#usage) - [API](#api) + - [Constructor](#constructor) - [Methods](#methods) - - [Metrics](#metrics) + - Expose [metrics](#metrics) ## Installation @@ -25,6 +26,7 @@ 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); @@ -33,10 +35,14 @@ 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); @@ -49,6 +55,7 @@ const response = await client.request({ }); // Abort the request. controller.abort(); +await client.close(); ``` ## API @@ -57,7 +64,6 @@ controller.abort(); ```js import client from '@podium/http-client'; - const client = new HttpClient(options); ``` @@ -67,8 +73,6 @@ const client = new HttpClient(options); |---------------------|--------------|------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------| | clientName | `''` | `string` | no | Client name | | connections | `50` | `number` | no | See [connections](#connections) | -| fallback | `undefined` | `function` | no | Function to call when requests fail, see [fallback](#fallback) | -| followRedirects | `false` | `boolean` | no | Flag for whether to follow redirects or not, see [followRedirects](#followRedirects). | | 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 | @@ -85,22 +89,6 @@ const client = new HttpClient(options); Property is sent to the underlying http library. See library docs on [connections](https://undici.nodejs.org/#/docs/api/Pool?id=parameter-pooloptions) -##### fallback - -Optional function to run when a request fails. - -```js -// TBA -``` - -##### followRedirects - -TODO!!! decide what to do with the redirects stuff... - -By default, the library does not follow redirect. -If set to true it will follow redirects according to `maxRedirections`. -It will by default throw on reaching `throwOnMaxRedirects` - ##### keepAliveMaxTimeout @@ -120,10 +108,9 @@ Console is also supported for easy test / development. Example: ```js -const layout = new Layout({ - name: 'myLayout', - pathname: '/foo', - logger: console, +import client from '@podium/http-client'; +const client = new HttpClient({ + logger: abslog(/* your logging library of choice **/), }); ``` @@ -158,33 +145,37 @@ If the client should throw on http 500 errors. If true, http 500 errors will cou 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. | -| signal | `AbortSignal` | Abort signal for canceling requests. | -| throwable | `boolean` | If we should throw on errors. | +| 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 it's connections. +Closes the client and all open connections. ### Metrics -The expose metrics on the circuit breaking behaviour. +The client expose a number of [@metrics/metric] to enabled developers to monitor its behaviour. -| name | type | description | -|------------------------------|---------|-------------------------------------------------------------------------------------------| -| `http_client_breaker_events` | counter | Counters on events exposed by the circuit breaker library,
see [opossum] for details | +| name | type | description | +|---------------------------------|---------------|-------------------------------------------------------------------------------------------| +| `http_client_breaker_events` | `counter` | Counters on events exposed by the circuit breaker library,
see [opossum] for details | +| `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. -[@metrics/metric]: https://github.com/metrics-js/metric '@metrics/metric' +[@metrics/metric]: https://metrics-js.github.io/reference/metric/ [abslog]: https://github.com/trygve-lie/abslog 'abslog' [undici]: https://undici.nodejs.org/ [opossum]: https://github.com/nodeshift/opossum/ diff --git a/lib/http-client.js b/lib/http-client.js index 9e0f4a6..1afcfe3 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -8,6 +8,11 @@ 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 @@ -15,7 +20,6 @@ import Metrics from '@metrics/client'; * @typedef HttpClientOptions * @property {string} [clientName] - client name * @property {Number} [connections=50] - @see https://undici.nodejs.org/#/docs/api/Pool?id=parameter-pooloptions - * @property {Function} [fallaback=undefined] - Optional function to call as a fallback when breaker is open. * @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 . @@ -44,7 +48,7 @@ export default class HttpClient { #throwOn500; /** - * @property {HttpClientOptions} options - options + * @property {HttpClientOptions} options - client options */ constructor({ clientName = '', @@ -132,7 +136,7 @@ export default class HttpClient { } /** - * + * Returns metrics object for the client. * @returns {import('@metrics/client')} */ get metrics() { @@ -140,6 +144,7 @@ export default class HttpClient { } /** + * Performs an http requests using the passed in options. * @param {HttpClientRequestOptionsAdditions | Object} options * @returns {Promise} */ @@ -208,15 +213,7 @@ export default class HttpClient { } /** - * Function called if the request fails. - * @param {import('opossum')} func - */ - #fallback(func) { - this.#breaker.fallback(func); - } - - /** - * Requests a URL. + * Perform an http request using the passed in options. * @param {HttpClientRequestOptions | Object} [options] * @returns {Promise} */ @@ -225,7 +222,7 @@ export default class HttpClient { } /** - * Closes the client. + * Closes the http client and all it's connectsions. * @returns {Promise} */ async close() { From c28301780ad5a962750dc6390b2822d4383b8152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Tue, 12 Nov 2024 13:00:35 +0100 Subject: [PATCH 24/32] fixing fallback bug --- lib/http-client.js | 10 ---------- tests/http-client.test.js | 33 --------------------------------- 2 files changed, 43 deletions(-) diff --git a/lib/http-client.js b/lib/http-client.js index 1afcfe3..6d10e94 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -53,7 +53,6 @@ export default class HttpClient { constructor({ clientName = '', connections = 50, - fallback = undefined, keepAliveMaxTimeout = undefined, keepAliveTimeout = undefined, logger = undefined, @@ -124,15 +123,6 @@ export default class HttpClient { connections, pipelining, }); - - if (fallback) { - if (typeof fallback === 'string') { - //@ts-ignore - this.#fallback(() => fallback); - } else { - this.#fallback(fallback); - } - } } /** diff --git a/tests/http-client.test.js b/tests/http-client.test.js index f502e33..8178af7 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -92,39 +92,6 @@ await test('http-client - basics', async (t) => { await client.close(); }); - await t.test('throws error when no fallback provided', async () => { - const client = new HttpClient(); - rejects( - async () => { - await client.request({ - path: '/', - origin: 'https://does-not-exist.domain', - method: 'GET', - }); - }, - { - name: 'Error', - }, - ); - await client.close(); - }); - await t.test('does not throw when fallback provided', async () => { - let isCaught = false; - const client = new HttpClient({ - fallback: () => { - isCaught = true; - }, - }); - - await client.request({ - path: '/', - origin: 'https://does-not-exist.domain', - method: 'GET', - }); - strictEqual(isCaught, true); - await client.close(); - }); - await closeServer(server); }); From c5ded43eebb1e2780ece563fa1c421e94ef9e6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Tue, 12 Nov 2024 13:40:51 +0100 Subject: [PATCH 25/32] fix removing success breaker event --- README.md | 15 ++++++++++----- lib/http-client.js | 2 +- tests/http-client.test.js | 8 +++----- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 37ef44e..bdac1b5 100644 --- a/README.md +++ b/README.md @@ -167,14 +167,19 @@ Closes the client and all open connections. The client expose a number of [@metrics/metric] to enabled developers to monitor its behaviour. -| name | type | description | -|---------------------------------|---------------|-------------------------------------------------------------------------------------------| -| `http_client_breaker_events` | `counter` | Counters on events exposed by the circuit breaker library,
see [opossum] for details | -| `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. | +| 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/ [abslog]: https://github.com/trygve-lie/abslog 'abslog' [undici]: https://undici.nodejs.org/ diff --git a/lib/http-client.js b/lib/http-client.js index 6d10e94..a698e2a 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -98,7 +98,7 @@ export default class HttpClient { //@ts-ignore const event = eventName; //@ts-ignore - if (['open', 'close', 'reject', 'success'].includes(event)) { + if (['open', 'close', 'reject'].includes(event)) { //@ts-ignore this.#breaker.on(event, (e) => { this.#logger.debug( diff --git a/tests/http-client.test.js b/tests/http-client.test.js index 8178af7..26c8fb1 100644 --- a/tests/http-client.test.js +++ b/tests/http-client.test.js @@ -338,19 +338,17 @@ await test('http-client: metrics', async (t) => { metrics.push(metric); }); client.metrics.on('end', () => { - strictEqual(metrics.length, 4, JSON.stringify(metrics)); + 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'); - - strictEqual(metrics[3].name, 'http_client_breaker_events'); - strictEqual(metrics[3].type, 2); - strictEqual(metrics[3].labels[0].value, 'success'); }); try { // Make the circuit open From f4ccca2ca82a78f278f1fc8f828074bb19eacb17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Tue, 12 Nov 2024 13:49:31 +0100 Subject: [PATCH 26/32] fix: bump --- lib/http-client.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/http-client.js b/lib/http-client.js index a698e2a..7ff38b7 100644 --- a/lib/http-client.js +++ b/lib/http-client.js @@ -78,6 +78,7 @@ export default class HttpClient { name: 'http_client_request_error', description: 'Requests resulting in an error', }); + this.#requestDuration = this.#metrics.histogram({ name: 'http_client_request_duration', description: 'Request duration', @@ -88,10 +89,12 @@ export default class HttpClient { }, 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()) { From b41acde6099ecf71386e43351f88b09f27ddc15d Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 12 Nov 2024 12:50:13 +0000 Subject: [PATCH 27/32] chore(release): 1.0.0-beta.8 [skip ci] # [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)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b76c5..84bece6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [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) diff --git a/package.json b/package.json index ed51d3a..39d8575 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@podium/http-client", - "version": "1.0.0-beta.7", + "version": "1.0.0-beta.8", "type": "module", "description": "The HTTP client used for all HTTP requests in Podium", "main": "lib/http-client.js", From 37e61f4b4627084c8a20d6aa3b8897317bc20f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Mon, 18 Nov 2024 15:32:19 +0100 Subject: [PATCH 28/32] chore: making the benchmarks run --- benchmarks/benchmark.js | 81 ++++++++++++++++------------------------- benchmarks/server.js | 4 ++ 2 files changed, 35 insertions(+), 50 deletions(-) diff --git a/benchmarks/benchmark.js b/benchmarks/benchmark.js index ff621f5..675f4ff 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([ @@ -196,7 +171,7 @@ const experiments = { }), ).on('finish', resolve); }); - }).catch(console.log); + }).catch(console.error); }, 'http - keepalive'() { @@ -210,12 +185,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 +201,7 @@ const experiments = { }), ); }) - .catch(console.log); + .catch(console.error); }); }, @@ -245,7 +220,7 @@ const experiments = { }), ) .on('finish', resolve); - }).catch(console.log); + }).catch(console.error); }, 'undici - request'() { @@ -261,28 +236,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 - request'() { + 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 +285,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..4a341c2 100644 --- a/benchmarks/server.js +++ b/benchmarks/server.js @@ -22,11 +22,15 @@ if (cluster.isPrimary) { cluster.fork(); } } else { + console.log(port); const buf = Buffer.alloc(64 * 1024, '_'); const server = createServer((req, res) => { setTimeout(function () { res.end(buf); }, timeout); }).listen(port); + server.on('error', (error) => { + console.error(error); + }); server.keepAliveTimeout = 600e3; } From 5c5c673d1cf9d651f54ed6b35bab792355145a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Mon, 18 Nov 2024 16:23:29 +0100 Subject: [PATCH 29/32] improving benchmark docs --- BENCHMARKS.md | 56 +++++++++++++++++++++++++++++++++++++++++ README.md | 3 ++- benchmarks/benchmark.js | 31 +++++++++++++++-------- benchmarks/server.js | 3 ++- benchmarks/wait.js | 20 --------------- 5 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 BENCHMARKS.md delete mode 100644 benchmarks/wait.js 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/README.md b/README.md index bdac1b5..2937836 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ Generic http client built on [undici] with a circuit breaker using [opossum], er - [API](#api) - [Constructor](#constructor) - [Methods](#methods) - - Expose [metrics](#metrics) + - Exposed [metrics](#metrics) + - [Benchmarks](./BENCHMARKS.md) ## Installation diff --git a/benchmarks/benchmark.js b/benchmarks/benchmark.js index 675f4ff..4432156 100644 --- a/benchmarks/benchmark.js +++ b/benchmarks/benchmark.js @@ -147,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, }); } @@ -240,7 +232,26 @@ const experiments = { }); }, - async 'podium-http-client - request'() { + 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 'podium-http-client'() { makeParallelRequests((resolve) => { httpClient .request({ diff --git a/benchmarks/server.js b/benchmarks/server.js index 4a341c2..7cd0d22 100644 --- a/benchmarks/server.js +++ b/benchmarks/server.js @@ -21,8 +21,8 @@ if (cluster.isPrimary) { for (let i = 0; i < workers; i++) { cluster.fork(); } + console.log(`${workers} workers available`); } else { - console.log(port); const buf = Buffer.alloc(64 * 1024, '_'); const server = createServer((req, res) => { setTimeout(function () { @@ -33,4 +33,5 @@ if (cluster.isPrimary) { 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); -}); From 8c67e3dc67db3a7085898aa287c01b5e00f6e8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?espen=20dall=C3=B8kken?= Date: Mon, 13 Jan 2025 13:53:24 +0100 Subject: [PATCH 30/32] fix: upgrading to undici v7 --- benchmarks/benchmark.js | 19 ------------------- package.json | 28 ++++++++++++++-------------- 2 files changed, 14 insertions(+), 33 deletions(-) diff --git a/benchmarks/benchmark.js b/benchmarks/benchmark.js index 4432156..9abc1ee 100644 --- a/benchmarks/benchmark.js +++ b/benchmarks/benchmark.js @@ -250,25 +250,6 @@ const experiments = { }).catch(console.error); return; }, - - 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() { diff --git a/package.json b/package.json index 39d8575..607bb20 100644 --- a/package.json +++ b/package.json @@ -35,26 +35,26 @@ }, "homepage": "https://github.com/podium-lib/http-client#readme", "dependencies": { - "@metrics/client": "2.5.3", + "@metrics/client": "2.5.4", "abslog": "2.4.4", "http-errors": "2.0.0", - "opossum": "8.3.0", - "undici": "6.20.1" + "opossum": "8.4.0", + "undici": "7.2.1" }, "devDependencies": { - "@podium/eslint-config": "1.0.2", + "@podium/eslint-config": "1.0.5", "@podium/semantic-release-config": "2.0.0", "@podium/typescript-config": "1.0.0", - "@types/node": "20.17.3", + "@types/node": "22.10.5", "@types/opossum": "8.1.8", - "@types/readable-stream": "4.0.15", - "concurrently": "9.0.1", - "cronometro": "4.0.0", - "eslint": "9.13.0", - "npm-run-all2": "6.2.6", - "prettier": "3.3.3", - "table": "6.8.2", - "typescript": "5.6.3", - "wait-on": "8.0.1" + "@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" } } From 9fff9019473d6bfc0b5120dbc2ad46b9f6fa8c38 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 13 Jan 2025 13:00:21 +0000 Subject: [PATCH 31/32] chore(release): 1.0.0-beta.9 [skip ci] # [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)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84bece6..5e3b41c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [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) diff --git a/package.json b/package.json index 607bb20..535809e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@podium/http-client", - "version": "1.0.0-beta.8", + "version": "1.0.0-beta.9", "type": "module", "description": "The HTTP client used for all HTTP requests in Podium", "main": "lib/http-client.js", From e53e4ec986eae1ed4881cbf52b7c6980bf3073f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Espen=20Dall=C3=B8kken?= Date: Tue, 14 Jan 2025 11:08:47 +0100 Subject: [PATCH 32/32] chore: updated docs a bit --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2937836..b927d97 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,15 @@ # @podium/http-client -⚠️ This project is still work in progress, should not be used for anything just yet. - -Generic http client built on [undici] with a circuit breaker using [opossum], error handling and metrics out of the box. +⚠️ This project is in beta, use at your own risk. Changes might occur. [![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) @@ -182,6 +185,8 @@ 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/