From 52bb6d2593b4a44ac9a476cb1ee921a05769a426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Thu, 26 Sep 2024 15:13:22 +0200 Subject: [PATCH] Add custom graceful shutdown handler --- lib/config.js | 1 + lib/core.js | 9 ++- lib/types/server/options.d.ts | 8 +++ test/core.js | 121 ++++++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 3 deletions(-) diff --git a/lib/config.js b/lib/config.js index 2b668f97d..685ecf144 100755 --- a/lib/config.js +++ b/lib/config.js @@ -285,6 +285,7 @@ internals.server = Validate.object({ .default(), routes: internals.routeBase.default(), state: Validate.object(), // Cookie defaults + stoppingHandler: Validate.function(), tls: Validate.alternatives([ Validate.object().allow(null), Validate.boolean() diff --git a/lib/core.js b/lib/core.js index 43329536f..b13ba0b18 100755 --- a/lib/core.js +++ b/lib/core.js @@ -81,6 +81,7 @@ exports = module.exports = internals.Core = class { toolkit = new Toolkit.Manager(); type = null; validator = null; + stoppingHandler = (req, res) => req.destroy(); extensionsSeq = 0; // Used to keep absolute order of extensions based on the order added across locations extensions = { @@ -131,6 +132,10 @@ exports = module.exports = internals.Core = class { this.validator = Validation.validator(this.settings.routes.validate.validator); } + if (typeof this.settings.stoppingHandler === 'function') { + this.stoppingHandler = this.settings.stoppingHandler; + } + this.listener = this._createListener(); this._initializeListener(); this.info = this._info(); @@ -506,11 +511,9 @@ exports = module.exports = internals.Core = class { return (req, res) => { - // $lab:coverage:off$ $not:allowsStoppedReq$ if (this.phase === 'stopping') { - return req.destroy(); + return this.stoppingHandler(req, res); } - // $lab:coverage:on$ $not:allowsStoppedReq$ // Create request diff --git a/lib/types/server/options.d.ts b/lib/types/server/options.d.ts index 68408152a..b5433e328 100644 --- a/lib/types/server/options.d.ts +++ b/lib/types/server/options.d.ts @@ -7,6 +7,7 @@ import { PluginSpecificConfiguration } from '../plugin'; import { RouteOptions } from '../route'; import { CacheProvider, ServerOptionsCache } from './cache'; import { SameSitePolicy } from './state'; +import { Lifecycle } from '../utils'; export interface ServerOptionsCompression { minBytes: number; @@ -219,6 +220,13 @@ export interface ServerOptions { encoding?: 'none' | 'base64' | 'base64json' | 'form' | 'iron' | undefined; } | undefined; + /** + * @default Destroys any incoming requests without further processing (client receives `ECONNRESET`). + * Custom handler to override the response to incoming request during the gracefully shutdown period. + * NOTE: The handler is called before decorating (and authenticating) the request object. The `req` object might be much simpler than the usual Lifecycle method. + */ + stoppingHandler?: Lifecycle.Method; + /** * @default none. * Used to create an HTTPS connection. The tls object is passed unchanged to the node HTTPS server as described in the node HTTPS documentation. diff --git a/test/core.js b/test/core.js index f2bde9f32..f6a6e652b 100755 --- a/test/core.js +++ b/test/core.js @@ -620,6 +620,127 @@ describe('Core', () => { await server.start(); await expect(server.stop()).to.reject('oops'); }); + + it('gracefully completes ongoing requests', async () => { + + const shutdownTimeout = 2_000; + const server = Hapi.server(); + server.route({ + method: 'get', path: '/', handler: async (_, res) => { + + await Hoek.wait(shutdownTimeout); + return res.response('ok'); + } + }); + await server.start(); + + const url = `http://localhost:${server.info.port}/`; + const req = Wreck.request('GET', url); + + // Stop the server while the request is in progress + await Hoek.wait(1_000); + const timer = new Hoek.Bench(); + await server.stop({ timeout: shutdownTimeout }); + expect(timer.elapsed()).to.be.greaterThan(900); // if the test takes less than 1s, the server is not holding for the request to complete (or there's a shortcut in the testkit) + expect(timer.elapsed()).to.be.lessThan(1_500); // it should be done in less than 1.5s, given that the request takes 2s and 1s has already passed (with a given offset) + + + const res = await req; + expect(res.statusCode).to.equal(200); + const body = await Wreck.read(res); + expect(body.toString()).to.equal('ok'); + expect(res.headers.connection).to.equal('close'); + await expect(req).to.not.reject(); + }); + + it('rejects incoming requests during the stopping phase', async () => { + + const shutdownTimeout = 4_000; + const server = Hapi.server(); + server.route({ + method: 'get', path: '/', handler: async (_, res) => { + + await Hoek.wait(shutdownTimeout); + return res.response('ok'); + } + }); + await server.start(); + + const url = `http://localhost:${server.info.port}/`; + + // Just performing one request to hold the server from immediately stopping. + const firstRequest = Wreck.request('GET', url); + + // Stop the server while the request is in progress + await Hoek.wait(1_000); + const timer = new Hoek.Bench(); + const stop = server.stop({ timeout: shutdownTimeout }); + + // Perform request after calling stop. + await Hoek.wait(1_000); + expect(server._core.phase).to.equal('stopping'); // Confirm that's still in `stopping` phase + const secondRequest = Wreck.request('GET', url); + expect(server._core.phase).to.equal('stopping'); + // await expect(secondRequest).to.reject('Client request error: socket hang up'); // it should be this one + await expect(secondRequest).to.reject('Client request error'); + expect(server._core.phase).to.equal('stopping'); + // await secondRequest.catch(({ code }) => expect(code).to.equal('ECONNRESET')); // it should be this one + await secondRequest.catch(({ code }) => expect(code).to.equal('ECONNREFUSED')); + + const { statusCode } = await firstRequest; + expect(statusCode).to.equal(200); + expect(server._core.phase).to.equal('stopped'); + await stop; + expect(timer.elapsed()).to.be.lessThan(shutdownTimeout); + await expect(firstRequest).to.not.reject(); + }); + + it('applies custom stopping handler during the stopping phase', async () => { + + const shutdownTimeout = 4_000; + const server = Hapi.server({ + stoppingHandler: (_, res) => { + + return res.response('server is shutting down').code(503); + } + }); + server.route({ + method: 'get', path: '/', handler: async (_, res) => { + + await Hoek.wait(shutdownTimeout); + return res.response('ok'); + } + }); + await server.start(); + + const url = `http://localhost:${server.info.port}/`; + + // Just performing one request to hold the server from immediately stopping. + const firstRequest = Wreck.request('GET', url); + + // Stop the server while the request is in progress + await Hoek.wait(1_000); + const timer = new Hoek.Bench(); + const stop = server.stop({ timeout: shutdownTimeout }); + + // Perform request after calling stop. + await Hoek.wait(1_000); + expect(server._core.phase).to.equal('stopping'); + const secondRequest = Wreck.request('GET', url); + // const secondRequest = Http.get(url); + expect(server._core.phase).to.equal('stopping'); + const responseToSecond = await secondRequest; + expect(responseToSecond.statusCode).to.equal(503); + await expect(Wreck.read(responseToSecond)).to.resolve('server is shutting down'); + expect(server._core.phase).to.equal('stopping'); + + const { statusCode } = await firstRequest; + expect(statusCode).to.equal(200); + expect(server._core.phase).to.equal('stopped'); + await stop; + expect(timer.elapsed()).to.be.lessThan(shutdownTimeout); + await expect(firstRequest).to.not.reject(); + }); }); describe('_init()', () => {