diff --git a/README.md b/README.md index 5b6e348..9d572d4 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ service.start(3000) middlewares: [], // Optional global value for routes "pathRegex". Default value: '/*' pathRegex: '/*', + // Optional global requests timeout value (given in milliseconds). Default value: '0' (DISABLED) + timeout: 0, // Optional "target" value that overrides the routes "target" config value. Feature intended for testing purposes. targetOverride: "https://yourdev.api-gateway.com", @@ -50,11 +52,15 @@ service.start(3000) // Optional `fast-proxy` library configuration (https://www.npmjs.com/package/fast-proxy#options) // base parameter defined as the route target. Default value: {} fastProxy: {}, + // Optional proxy handler function. Default value: (req, res, url, proxy, proxyOpts) => proxy(req, res, url, proxyOpts) + proxyHandler: () => {} // Optional flag to indicate if target uses the HTTP2 protocol. Default value: false http2: false, // Optional path matching regex. Default value: '/*' // In order to disable the 'pathRegex' at all, you can use an empty string: '' pathRegex: '/*', + // Optional service requests timeout value (given in milliseconds). Default value: '0' (DISABLED) + timeout: 0, // route prefix prefix: '/public', // Optional documentation configuration (unrestricted schema) @@ -133,6 +139,19 @@ Example output: ``` > NOTE: Please see `docs` configuration entry explained above. +## Timeouts and Unavailability +We can restrict requests timeouts globally, at service level using the `timeout` configuration. +To define an endpoint specific timeout, you can use the property `timeout` of the request object, normally inside a middleware: +```js +req.timeout = 500 // define a 500ms timeout on a custom request. +``` +> NOTE: You might want to also check https://www.npmjs.com/package/middleware-if-unless + +### Circuit Breaker +By using the `proxyHandler` hook, developers can optionally intercept and modify the default gateway routing behavior right before the origin request is proxied to the remote service. Therefore, connecting advanced monitoring mechanisms like [Circuit Breakers](https://martinfowler.com/bliki/CircuitBreaker.html) is rather simple. + +Please see the `demos/circuitbreaker.js` example for more details using the `opossum` library. + ## Gateway level caching Caching support is provided by the `http-cache-middleware` module. https://www.npmjs.com/package/http-cache-middleware diff --git a/demos/circuitbreaker.js b/demos/circuitbreaker.js new file mode 100644 index 0000000..7bff9ca --- /dev/null +++ b/demos/circuitbreaker.js @@ -0,0 +1,59 @@ +const gateway = require('../index') +const PORT = process.env.PORT || 8080 +const onEnd = require('on-http-end') +const CircuitBreaker = require('opossum') + +const REQUEST_TIMEOUT = 1.5 * 1000 + +const options = { + timeout: REQUEST_TIMEOUT - 200, // If our function takes longer than "timeout", trigger a failure + errorThresholdPercentage: 50, // When 50% of requests fail, trip the circuit + resetTimeout: 30 * 1000 // After 30 seconds, try again. +} +const breaker = new CircuitBreaker(([req, res, url, proxy, proxyOpts]) => { + return new Promise((resolve, reject) => { + proxy(req, res, url, proxyOpts) + onEnd(res, () => { + // you can optionally evaluate response codes here... + resolve() + }) + }) +}, options) + +breaker.fallback(([req, res], err) => { + if (err.code === 'EOPENBREAKER') { + res.send({ + message: 'Upps, looks like "public" service is down. Please try again in 30 seconds!' + }, 503) + } +}) + +gateway({ + routes: [{ + timeout: REQUEST_TIMEOUT, + proxyHandler: (...params) => breaker.fire(params), + prefix: '/public', + target: 'http://localhost:3000', + docs: { + name: 'Public Service', + endpoint: 'swagger.json', + type: 'swagger' + } + }] +}).start(PORT).then(() => { + console.log(`API Gateway listening on ${PORT} port!`) +}) + +const service = require('restana')({}) +service.get('/longop', (req, res) => { + setTimeout(() => { + res.send('This operation will trigger the breaker failure counter...') + }, 2000) +}) +service.get('/hi', (req, res) => { + res.send('Hello World!') +}) + +service.start(3000).then(() => { + console.log('Public service listening on 3000 port!') +}) diff --git a/index.js b/index.js index ee0349d..5016310 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ const fastProxy = require('fast-proxy') const restana = require('restana') const pump = require('pump') const toArray = require('stream-to-array') +const defaultProxyHandler = (req, res, url, proxy, proxyOpts) => proxy(req, res, url, proxyOpts) const gateway = (opts) => { opts = Object.assign({ @@ -48,19 +49,31 @@ const gateway = (opts) => { ...(opts.fastProxy) }) + // route proxy handler function + const proxyHandler = route.proxyHandler || defaultProxyHandler + + // populating timeout config + route.timeout = route.timeout || opts.timeout + // registering route handler const methods = route.methods || ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS'] - server.route(methods, route.prefix + route.pathRegex, handler(route, proxy), null, route.middlewares) + server.route(methods, route.prefix + route.pathRegex, handler(route, proxy, proxyHandler), null, route.middlewares) }) return server } -const handler = (route, proxy) => async (req, res) => { +const handler = (route, proxy, proxyHandler) => async (req, res) => { req.url = req.url.replace(route.prefix, route.prefixRewrite) const shouldAbortProxy = await route.hooks.onRequest(req, res) if (!shouldAbortProxy) { - proxy(req, res, req.url, Object.assign({}, route.hooks)) + const proxyOpts = Object.assign({ + request: { + timeout: req.timeout || route.timeout + } + }, route.hooks) + + proxyHandler(req, res, req.url, proxy, proxyOpts) } } diff --git a/package-lock.json b/package-lock.json index 3e082ac..36bef8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "fast-gateway", - "version": "1.3.9", + "version": "1.4.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -446,9 +446,9 @@ } }, "commander": { - "version": "2.20.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.1.tgz", - "integrity": "sha512-cCuLsMhJeWQ/ZpsFTbE765kvVfoeSddc4nU3up4fV+fDBcfUXnbITJ+JzhkdjzOqhURjZgujxaioam4RM9yGUg==", + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, "optional": true }, @@ -1175,9 +1175,9 @@ "dev": true }, "fast-proxy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-proxy/-/fast-proxy-1.2.0.tgz", - "integrity": "sha512-zQTMFD3VW1ArenqmJhfWldJki9ZOuKWnV+Fm+WaR1Aq6RCtDSQbaz2GECDGFhtGa+PmjAagSQm/kyVZMjhAFHQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-proxy/-/fast-proxy-1.3.0.tgz", + "integrity": "sha512-jXJa3/zufGOR9EShztpqoguL0GjLbWMp3z/BKQLMfylda+OONcT+FGIvD87p7vM2CIUISf+HNG0k3jAbNLjlJQ==", "requires": { "end-of-stream": "^1.4.4", "pump": "^3.0.0", @@ -1481,9 +1481,9 @@ "dev": true }, "handlebars": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.4.0.tgz", - "integrity": "sha512-xkRtOt3/3DzTKMOt3xahj2M/EqNhY988T+imYSlMgs5fVhLN2fmKVVj0LtEGmb+3UUYV5Qmm1052Mm3dIQxOvw==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.5.2.tgz", + "integrity": "sha512-29Zxv/cynYB7mkT1rVWQnV7mGX6v7H/miQ6dbEpYTKq5eJBN7PsRB+ViYJlcT6JINTSu4dVB9kOqEun78h6Exg==", "dev": true, "requires": { "neo-async": "^2.6.0", @@ -2704,6 +2704,12 @@ "mimic-fn": "^1.0.0" } }, + "opossum": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/opossum/-/opossum-4.2.1.tgz", + "integrity": "sha512-V0xLXTM24JhjvS+LP+2yT7CYEKiNo3zuvMaxsJrDeAIwGNmGuNZrqcAfIiRmFWdGGpA0HpybXOLQ2OOaAI3ATQ==", + "dev": true + }, "optimist": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", @@ -3777,13 +3783,13 @@ "dev": true }, "uglify-js": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.0.tgz", - "integrity": "sha512-W+jrUHJr3DXKhrsS7NUVxn3zqMOFn0hL/Ei6v0anCIMoKC93TjcflTagwIHLW7SfMFfiQuktQyFVCFHGUE0+yg==", + "version": "3.6.9", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.9.tgz", + "integrity": "sha512-pcnnhaoG6RtrvHJ1dFncAe8Od6Nuy30oaJ82ts6//sGSXOP5UjBMEthiProjXmMNHOfd93sqlkztifFMcb+4yw==", "dev": true, "optional": true, "requires": { - "commander": "~2.20.0", + "commander": "~2.20.3", "source-map": "~0.6.1" }, "dependencies": { diff --git a/package.json b/package.json index 51f2980..ec276f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fast-gateway", - "version": "1.3.9", + "version": "1.4.0", "description": "A Node.js API Gateway for the masses!", "main": "index.js", "scripts": { @@ -26,7 +26,7 @@ }, "homepage": "https://github.com/jkyberneees/fast-gateway#readme", "dependencies": { - "fast-proxy": "^1.2.0", + "fast-proxy": "^1.3.0", "http-cache-middleware": "^1.2.3", "restana": "^3.3.3", "stream-to-array": "^2.3.0" @@ -38,6 +38,7 @@ "helmet": "^3.21.2", "mocha": "^6.2.2", "nyc": "^14.1.1", + "opossum": "^4.2.1", "response-time": "^2.3.2", "standard": "^14.3.1", "supertest": "^4.0.2" diff --git a/test/config.js b/test/config.js index a02acb4..2cf3f9d 100644 --- a/test/config.js +++ b/test/config.js @@ -2,6 +2,8 @@ const pump = require('pump') module.exports = async () => { return { + timeout: 1.5 * 1000, + middlewares: [ require('cors')(), require('http-cache-middleware')() diff --git a/test/smoke.test.js b/test/smoke.test.js index 8d123e8..8a046bf 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -33,6 +33,11 @@ describe('API Gateway', () => { res.setHeader('x-cache-expire', 'GET/users/*') res.send({}) }) + remote.get('/longop', (req, res) => { + setTimeout(() => { + res.send({}) + }, 2000) + }) remote.post('/204', (req, res) => res.send(204)) remote.get('/endpoint-proxy-methods', (req, res) => res.send({ name: 'endpoint-proxy-methods' @@ -155,6 +160,12 @@ describe('API Gateway', () => { }) }) + it('Should timeout on GET /longop - 504', async () => { + return request(gateway) + .get('/users/longop') + .expect(504) + }) + it('GET /users/info - 200', async () => { await request(gateway) .get('/users/info')