Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP - imported from other module #10

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,76 @@

Generic http client built on [undici](undici.nodejs.org/) with a circuit breaker, error handling and metrics out of the box.

[![Dependencies](https://img.shields.io/david/podium-lib/http-client.svg)](https://david-dm.org/podium-lib/http-client)
[![GitHub Actions status](https://github.com/podium-lib/http-client/workflows/Run%20Lint%20and%20Tests/badge.svg)](https://github.com/podium-lib/layout/actions?query=workflow%3A%22Run+Lint+and+Tests%22)
[![Known Vulnerabilities](https://snyk.io/test/github/podium-lib/http-client/badge.svg)](https://snyk.io/test/github/podium-lib/http-client)

## 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 |
|------------|---------|-----------|----------|--------------------------------------------------------------------------------------------------------------------------------------------|
| threshold | `null` | `number` | `25` | Circuit breaker: How many, in %, requests should error before the circuit should trip. Ex; when 25% of requests fail, trip the circuit. |
| timeout | `null` | `number` | `500` | Circuit breaker: How long, in milliseconds, a request can maximum take. Requests exceeding this limit counts against tripping the circuit. |
| throwOn400 | `false` | `boolean` | `false` | If the client sahould throw on HTTP 400 errors.If true, HTTP 400 errors will counts against tripping the circuit. |
| throwOn500 | `false` | `boolean` | `true` | If the client sahould throw on HTTP 500 errors.If true, HTTP 500 errors will counts against tripping the circuit. |
| reset | `false` | `number` | `2000` | Circuit breaker: How long, in milliseconds, to wait before a tripped circuit should be reset. |
| logger | `null` | `àb` | `false` | A logger which conform to a log4j interface |


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


## Methods

### async request(options)
### async close()
### fallback()


[@metrics/metric]: https://github.com/metrics-js/metric '@metrics/metric'
[abslog]: https://github.com/trygve-lie/abslog 'abslog'
49 changes: 33 additions & 16 deletions lib/http-client.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Agent, setGlobalDispatcher, request, MockAgent } from 'undici';
import { Agent, request } from 'undici';
import createError from 'http-errors';
import Opossum from 'opossum';
import abslog from 'abslog';
import EventEmitter from 'node:events';

/**
* @typedef HttpClientOptions
Expand All @@ -17,17 +18,20 @@ import abslog from 'abslog';
* @property {Number} reset - Circuit breaker: How long, in milliseconds, to wait before a tripped circuit should be reset.
**/

export default class PodiumHttpClient {
export default class HttpClient extends EventEmitter {
#throwOn400;
#throwOn500;
#breaker;
#logger;
#agent;
#abortController;

/**
* @property {HttpClientOptions} options - options
*/
constructor({
abortController = undefined,
autoRenewAbortController = false,
keepAliveMaxTimeout = undefined,
keepAliveTimeout = undefined,
connections = 50,
Expand All @@ -38,13 +42,21 @@ export default class PodiumHttpClient {
timeout = 500,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this timeout should be 1000. Wonder why @trygve-lie went for 500 🤔

logger = undefined,
reset = 20000,
fallback = undefined,
} = {}) {
super();
this.#logger = abslog(logger);
this.#throwOn400 = throwOn400;
this.#throwOn500 = throwOn500;
// Add runtime check
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, // 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
Expand All @@ -56,6 +68,17 @@ export default class PodiumHttpClient {
connections,
pipelining, // TODO unknown option, consider removing
});

if (fallback) {
this.fallback(fallback);
}

this.#breaker.on('open', () => {
this.emit('open');
});
leftieFriele marked this conversation as resolved.
Show resolved Hide resolved
this.#breaker.on('close', () => {
this.emit('close');
});
}

async #request(options = {}) {
Expand All @@ -64,7 +87,7 @@ export default class PodiumHttpClient {
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);
Expand All @@ -74,7 +97,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);
Expand All @@ -88,12 +111,12 @@ export default class PodiumHttpClient {
};
}

fallback(fn) {
this.#breaker.fallback(fn);
}

metrics() {
// TODO: Implement...
/**
* Function called if the request fails.
* @param {Function} func
*/
fallback(func) {
this.#breaker.fallback(func);
}

async request(options = {}) {
Expand All @@ -106,10 +129,4 @@ export default class PodiumHttpClient {
await this.#agent.close();
}
}

static mock(origin) {
const agent = new MockAgent();
setGlobalDispatcher(agent);
return agent.get(origin);
}
}
153 changes: 109 additions & 44 deletions tests/http-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,86 +2,151 @@ 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) {
await client.close();
await httpServer.close();
function beforeEach() {
httpServer = http.createServer(async (request, response) => {
response.writeHead(200);
response.end();
});
httpServer.listen(port, host);
}

test('http-client - basics', async (t) => {
t.beforeEach(async function () {
httpServer = http.createServer(async (request, response) => {
response.writeHead(200);
response.end();
});
httpServer.listen(port, host, () => Promise.resolve());
function closeServer(s) {
return new Promise((resolve) => {
s.close(resolve);
});
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);
},
);
}
async function afterEach(client, server = httpServer) {
await client.close();
await closeServer(server);
}

await t.test('does not cause havoc with built in fetch', async () => {
const url = `http://${host}:${port}`;
const client = new PodiumHttpClient();
await fetch(url);
async function queryUrl({
client,
path = '/',
url = `http://${host}:${port}`,
loop = 2,
supresErrors = true,
}) {
for (let i = 0; i < loop; i++) {
try {
await client.request({ path, origin: url, method: 'GET' });
} catch (err) {
if (!supresErrors) throw err;
}
}
}
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',
});
assert.strictEqual(response.statusCode, 200);
await client.close();
await fetch(url);
await afterEach(client);
});

test.skip('http-client: should not invalid port input', async () => {
const url = `http://${host}:3013`;
const client = new PodiumHttpClient();
await client.request({
path: '/',
origin: url,
method: 'GET',
});
await t.test('does not cause havoc with built in fetch', async () => {
const client = new HttpClient();
await fetch(url);
const response = await client.request({
path: '/',
origin: url,
method: 'GET',
});
assert.strictEqual(response.statusCode, 200);
await client.close();
await fetch(url);
await client.close();
});
await closeServer(server);
});

test.skip('http-client circuit breaker behaviour', async (t) => {
await t.test('closes on failure threshold', async () => {
const url = `http://${host}:3014`;
const client = new PodiumHttpClient({ threshold: 2 });
await test('http-client - 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.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();
});

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 hasOpened = false;
client.on('open', () => {
hasOpened = true;
});
await queryUrl({ client, url: invalidUrl });

assert.strictEqual(hasOpened, true);
await afterEach(client);
});
await t.test('can reset breaker', async () => {
beforeEach();
const invalidUrl = `http://${host}:3013`;
const client = new HttpClient({ threshold: 50, reset: 1 });
await queryUrl({ client, url: invalidUrl });

let hasClosed = false;
client.on('close', () => {
hasClosed = true;
});
await wait();
const response = await client.request({
path: '/',
origin: url,
method: 'GET',
});
assert.strictEqual(hasClosed, true);
assert.strictEqual(response.statusCode, 200);
await afterEach(client);
});
Expand Down
Loading