Skip to content

Commit fe87306

Browse files
committed
adding breaker tests++
1 parent 10b2301 commit fe87306

File tree

4 files changed

+176
-55
lines changed

4 files changed

+176
-55
lines changed

README.md

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,76 @@
44

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

7-
[![Dependencies](https://img.shields.io/david/podium-lib/http-client.svg)](https://david-dm.org/podium-lib/http-client)
87
[![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)
98
[![Known Vulnerabilities](https://snyk.io/test/github/podium-lib/http-client/badge.svg)](https://snyk.io/test/github/podium-lib/http-client)
109

1110
## Installation
1211

12+
*Note!* Requires Node.js v20 or later.
13+
1314
```bash
14-
$ npm install @podium/http-client
15+
npm install @podium/http-client
1516
```
1617

1718
## Usage
1819

20+
```js
21+
import client from '@podium/http-client';
22+
const client = new HttpClient(options);
23+
24+
const response = await client.request({ path: '/', origin: 'https://host.domain' })
25+
if (response.ok) {
26+
//
27+
}
28+
```
29+
30+
## API
31+
32+
### Constructor
1933

2034
```js
35+
import client from '@podium/http-client';
36+
37+
const client = new HttpClient(options);
38+
```
39+
40+
#### options
41+
42+
| option | default | type | required | details |
43+
|------------|---------|-----------|----------|--------------------------------------------------------------------------------------------------------------------------------------------|
44+
| 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. |
45+
| timeout | `null` | `number` | `500` | Circuit breaker: How long, in milliseconds, a request can maximum take. Requests exceeding this limit counts against tripping the circuit. |
46+
| throwOn400 | `false` | `boolean` | `false` | If the client sahould throw on HTTP 400 errors.If true, HTTP 400 errors will counts against tripping the circuit. |
47+
| throwOn500 | `false` | `boolean` | `true` | If the client sahould throw on HTTP 500 errors.If true, HTTP 500 errors will counts against tripping the circuit. |
48+
| reset | `false` | `number` | `2000` | Circuit breaker: How long, in milliseconds, to wait before a tripped circuit should be reset. |
49+
| logger | `null` | `àb` | `false` | A logger which conform to a log4j interface |
2150

51+
52+
##### logger
53+
54+
Any log4j compatible logger can be passed in and will be used for logging.
55+
Console is also supported for easy test / development.
56+
57+
Example:
58+
59+
```js
60+
const layout = new Layout({
61+
name: 'myLayout',
62+
pathname: '/foo',
63+
logger: console,
64+
});
2265
```
2366

24-
## Constructor
67+
Under the hood [abslog] is used to abstract out logging. Please see [abslog] for
68+
further details.
69+
70+
71+
## Methods
72+
73+
### async request(options)
74+
### async close()
75+
### fallback()
76+
77+
78+
[@metrics/metric]: https://github.com/metrics-js/metric '@metrics/metric'
79+
[abslog]: https://github.com/trygve-lie/abslog 'abslog'

lib/http-client.js

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Agent, setGlobalDispatcher, request, MockAgent } from 'undici';
22
import createError from 'http-errors';
33
import Opossum from 'opossum';
44
import abslog from 'abslog';
5+
import EventEmitter from 'node:events';
56

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

20-
export default class HttpClient {
21+
export default class HttpClient extends EventEmitter {
2122
#throwOn400;
2223
#throwOn500;
2324
#breaker;
2425
#logger;
2526
#agent;
27+
#abortController;
2628

2729
/**
2830
* @property {HttpClientOptions} options - options
2931
*/
3032
constructor({
33+
abortController = undefined,
3134
keepAliveMaxTimeout = undefined,
3235
keepAliveTimeout = undefined,
3336
connections = 50,
@@ -38,13 +41,20 @@ export default class HttpClient {
3841
timeout = 500,
3942
logger = undefined,
4043
reset = 20000,
44+
fallback = undefined,
4145
} = {}) {
46+
super();
4247
this.#logger = abslog(logger);
4348
this.#throwOn400 = throwOn400;
4449
this.#throwOn500 = throwOn500;
50+
// Add runtime check
51+
this.#abortController = abortController;
4552

4653
// TODO; Can we avoid bind here in a nice way?????
4754
this.#breaker = new Opossum(this.#request.bind(this), {
55+
...(this.#abortController && {
56+
abortController: this.#abortController,
57+
}),
4858
errorThresholdPercentage: threshold, // When X% of requests fail, trip the circuit
4959
resetTimeout: reset, // After X milliseconds, try again.
5060
timeout, // If our function takes longer than X milliseconds, trigger a failure
@@ -56,6 +66,17 @@ export default class HttpClient {
5666
connections,
5767
pipelining, // TODO unknown option, consider removing
5868
});
69+
70+
if (fallback) {
71+
this.fallback(fallback);
72+
}
73+
74+
this.#breaker.on('open', () => {
75+
this.emit('open');
76+
});
77+
this.#breaker.on('close', () => {
78+
this.emit('close');
79+
});
5980
}
6081

6182
async #request(options = {}) {
@@ -88,8 +109,12 @@ export default class HttpClient {
88109
};
89110
}
90111

91-
fallback(fn) {
92-
this.#breaker.fallback(fn);
112+
/**
113+
* Function called if the request fails.
114+
* @param {Function} func
115+
*/
116+
fallback(func) {
117+
this.#breaker.fallback(func);
93118
}
94119

95120
async request(options = {}) {
@@ -103,9 +128,9 @@ export default class HttpClient {
103128
}
104129
}
105130

106-
static mock(origin) {
107-
const agent = new MockAgent();
108-
setGlobalDispatcher(agent);
109-
return agent.get(origin);
110-
}
131+
// static mock(origin) {
132+
// const agent = new MockAgent();
133+
// setGlobalDispatcher(agent);
134+
// return agent.get(origin);
135+
// }
111136
}

tests/http-client.test.js

Lines changed: 73 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,91 +3,120 @@ import assert from 'node:assert/strict';
33
import http from 'node:http';
44

55
import HttpClient from '../lib/http-client.js';
6+
import { wait } from './utilities.js';
67

78
let httpServer,
89
host = 'localhost',
910
port = 3003;
1011

11-
async function beforeEach() {
12+
const url = `http://${host}:${port}`;
13+
14+
function beforeEach() {
1215
httpServer = http.createServer(async (request, response) => {
1316
response.writeHead(200);
1417
response.end();
1518
});
16-
httpServer.listen(port, host, () => Promise.resolve());
19+
httpServer.listen(port, host);
1720
}
1821

22+
function closeServer() {
23+
return new Promise((resolve) => {
24+
httpServer.close(resolve);
25+
console.log('Closed');
26+
});
27+
}
1928
async function afterEach(client) {
2029
await client.close();
21-
await httpServer.close();
30+
await closeServer();
2231
}
2332

24-
test('http-client - basics', async (t) => {
25-
await t.test(
26-
'http-client: returns 200 response when given valid input',
27-
async () => {
28-
await beforeEach();
29-
const url = `http://${host}:${port}`;
30-
const client = new HttpClient();
31-
const response = await client.request({
32-
path: '/',
33-
origin: url,
34-
method: 'GET',
35-
});
36-
assert.strictEqual(response.statusCode, 200);
37-
await afterEach(client);
38-
},
39-
);
40-
41-
await t.test('does not cause havoc with built in fetch', async () => {
42-
await beforeEach();
43-
const url = `http://${host}:${port}`;
33+
async function queryUrl({
34+
client,
35+
path = '/',
36+
url = `http://${host}:${port}`,
37+
loop = 2,
38+
supresErrors = true,
39+
}) {
40+
for (let i = 0; i < loop; i++) {
41+
try {
42+
await client.request({ path, origin: url, method: 'GET' });
43+
} catch (err) {
44+
if (!supresErrors) throw err;
45+
}
46+
}
47+
}
48+
await test('http-client - basics', async (t) => {
49+
await t.test('returns 200 response when given valid input', async () => {
50+
beforeEach();
4451
const client = new HttpClient();
45-
await fetch(url);
4652
const response = await client.request({
4753
path: '/',
4854
origin: url,
4955
method: 'GET',
5056
});
5157
assert.strictEqual(response.statusCode, 200);
52-
await client.close();
53-
await fetch(url);
5458
await afterEach(client);
5559
});
5660

57-
await test.skip('http-client: should not invalid port input', async () => {
58-
await beforeEach();
59-
const url = `http://${host}:3013`;
61+
// await t.test('does not cause havoc with built in fetch', async () => {
62+
// beforeEach();
63+
// const client = new HttpClient();
64+
// await fetch(url);
65+
// const response = await client.request({
66+
// path: '/',
67+
// origin: url,
68+
// method: 'GET',
69+
// });
70+
// assert.strictEqual(response.statusCode, 200);
71+
// await client.close();
72+
// await fetch(url);
73+
// await afterEach(client);
74+
// });
75+
await t.test('can pass in an abort controller', async () => {
76+
beforeEach();
77+
const abortController = new AbortController();
78+
6079
const client = new HttpClient();
61-
await client.request({
62-
path: '/',
63-
origin: url,
64-
method: 'GET',
65-
});
6680
const response = await client.request({
6781
path: '/',
6882
origin: url,
6983
method: 'GET',
7084
});
71-
assert.strictEqual(response.statusCode, 200);
7285
await afterEach(client);
7386
});
7487
});
7588

76-
test.skip('http-client circuit breaker behaviour', async (t) => {
77-
await t.test('closes on failure threshold', async () => {
78-
await beforeEach();
79-
const url = `http://${host}:3014`;
80-
const client = new HttpClient({ threshold: 2 });
81-
await client.request({
82-
path: '/',
83-
origin: url,
84-
method: 'GET',
89+
await test('http-client - circuit breaker behaviour', async (t) => {
90+
await t.test('opens on failure threshold', async () => {
91+
beforeEach();
92+
const invalidUrl = `http://${host}:3013`;
93+
const client = new HttpClient({ threshold: 50 });
94+
let hasOpened = false;
95+
client.on('open', () => {
96+
hasOpened = true;
97+
});
98+
await queryUrl({ client, url: invalidUrl });
99+
100+
assert.strictEqual(hasOpened, true);
101+
await afterEach(client);
102+
});
103+
await t.test('can reset breaker', async () => {
104+
beforeEach();
105+
const invalidUrl = `http://${host}:3013`;
106+
const client = new HttpClient({ threshold: 50, reset: 1 });
107+
await queryUrl({ client, url: invalidUrl });
108+
109+
let hasClosed = false;
110+
client.on('close', () => {
111+
hasClosed = true;
85112
});
113+
await wait();
86114
const response = await client.request({
87115
path: '/',
88116
origin: url,
89117
method: 'GET',
90118
});
119+
assert.strictEqual(hasClosed, true);
91120
assert.strictEqual(response.statusCode, 200);
92121
await afterEach(client);
93122
});

tests/utilities.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Wait a little bit, promise based.
3+
* @property {Number} [waitTime=1000]
4+
* @returns {Promise<void>}
5+
*/
6+
export async function wait(waitTime = 1000) {
7+
return new Promise((resolve) => {
8+
setTimeout(async () => {
9+
resolve();
10+
}, waitTime);
11+
});
12+
}

0 commit comments

Comments
 (0)