Skip to content

Commit 52bb6d2

Browse files
committed
Add custom graceful shutdown handler
1 parent 22377ee commit 52bb6d2

File tree

4 files changed

+136
-3
lines changed

4 files changed

+136
-3
lines changed

lib/config.js

+1
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ internals.server = Validate.object({
285285
.default(),
286286
routes: internals.routeBase.default(),
287287
state: Validate.object(), // Cookie defaults
288+
stoppingHandler: Validate.function(),
288289
tls: Validate.alternatives([
289290
Validate.object().allow(null),
290291
Validate.boolean()

lib/core.js

+6-3
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ exports = module.exports = internals.Core = class {
8181
toolkit = new Toolkit.Manager();
8282
type = null;
8383
validator = null;
84+
stoppingHandler = (req, res) => req.destroy();
8485

8586
extensionsSeq = 0; // Used to keep absolute order of extensions based on the order added across locations
8687
extensions = {
@@ -131,6 +132,10 @@ exports = module.exports = internals.Core = class {
131132
this.validator = Validation.validator(this.settings.routes.validate.validator);
132133
}
133134

135+
if (typeof this.settings.stoppingHandler === 'function') {
136+
this.stoppingHandler = this.settings.stoppingHandler;
137+
}
138+
134139
this.listener = this._createListener();
135140
this._initializeListener();
136141
this.info = this._info();
@@ -506,11 +511,9 @@ exports = module.exports = internals.Core = class {
506511

507512
return (req, res) => {
508513

509-
// $lab:coverage:off$ $not:allowsStoppedReq$
510514
if (this.phase === 'stopping') {
511-
return req.destroy();
515+
return this.stoppingHandler(req, res);
512516
}
513-
// $lab:coverage:on$ $not:allowsStoppedReq$
514517

515518
// Create request
516519

lib/types/server/options.d.ts

+8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { PluginSpecificConfiguration } from '../plugin';
77
import { RouteOptions } from '../route';
88
import { CacheProvider, ServerOptionsCache } from './cache';
99
import { SameSitePolicy } from './state';
10+
import { Lifecycle } from '../utils';
1011

1112
export interface ServerOptionsCompression {
1213
minBytes: number;
@@ -219,6 +220,13 @@ export interface ServerOptions {
219220
encoding?: 'none' | 'base64' | 'base64json' | 'form' | 'iron' | undefined;
220221
} | undefined;
221222

223+
/**
224+
* @default Destroys any incoming requests without further processing (client receives `ECONNRESET`).
225+
* Custom handler to override the response to incoming request during the gracefully shutdown period.
226+
* NOTE: The handler is called before decorating (and authenticating) the request object. The `req` object might be much simpler than the usual Lifecycle method.
227+
*/
228+
stoppingHandler?: Lifecycle.Method;
229+
222230
/**
223231
* @default none.
224232
* Used to create an HTTPS connection. The tls object is passed unchanged to the node HTTPS server as described in the node HTTPS documentation.

test/core.js

+121
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,127 @@ describe('Core', () => {
620620
await server.start();
621621
await expect(server.stop()).to.reject('oops');
622622
});
623+
624+
it('gracefully completes ongoing requests', async () => {
625+
626+
const shutdownTimeout = 2_000;
627+
const server = Hapi.server();
628+
server.route({
629+
method: 'get', path: '/', handler: async (_, res) => {
630+
631+
await Hoek.wait(shutdownTimeout);
632+
return res.response('ok');
633+
}
634+
});
635+
await server.start();
636+
637+
const url = `http://localhost:${server.info.port}/`;
638+
const req = Wreck.request('GET', url);
639+
640+
// Stop the server while the request is in progress
641+
await Hoek.wait(1_000);
642+
const timer = new Hoek.Bench();
643+
await server.stop({ timeout: shutdownTimeout });
644+
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)
645+
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)
646+
647+
648+
const res = await req;
649+
expect(res.statusCode).to.equal(200);
650+
const body = await Wreck.read(res);
651+
expect(body.toString()).to.equal('ok');
652+
expect(res.headers.connection).to.equal('close');
653+
await expect(req).to.not.reject();
654+
});
655+
656+
it('rejects incoming requests during the stopping phase', async () => {
657+
658+
const shutdownTimeout = 4_000;
659+
const server = Hapi.server();
660+
server.route({
661+
method: 'get', path: '/', handler: async (_, res) => {
662+
663+
await Hoek.wait(shutdownTimeout);
664+
return res.response('ok');
665+
}
666+
});
667+
await server.start();
668+
669+
const url = `http://localhost:${server.info.port}/`;
670+
671+
// Just performing one request to hold the server from immediately stopping.
672+
const firstRequest = Wreck.request('GET', url);
673+
674+
// Stop the server while the request is in progress
675+
await Hoek.wait(1_000);
676+
const timer = new Hoek.Bench();
677+
const stop = server.stop({ timeout: shutdownTimeout });
678+
679+
// Perform request after calling stop.
680+
await Hoek.wait(1_000);
681+
expect(server._core.phase).to.equal('stopping'); // Confirm that's still in `stopping` phase
682+
const secondRequest = Wreck.request('GET', url);
683+
expect(server._core.phase).to.equal('stopping');
684+
// await expect(secondRequest).to.reject('Client request error: socket hang up'); // it should be this one
685+
await expect(secondRequest).to.reject('Client request error');
686+
expect(server._core.phase).to.equal('stopping');
687+
// await secondRequest.catch(({ code }) => expect(code).to.equal('ECONNRESET')); // it should be this one
688+
await secondRequest.catch(({ code }) => expect(code).to.equal('ECONNREFUSED'));
689+
690+
const { statusCode } = await firstRequest;
691+
expect(statusCode).to.equal(200);
692+
expect(server._core.phase).to.equal('stopped');
693+
await stop;
694+
expect(timer.elapsed()).to.be.lessThan(shutdownTimeout);
695+
await expect(firstRequest).to.not.reject();
696+
});
697+
698+
it('applies custom stopping handler during the stopping phase', async () => {
699+
700+
const shutdownTimeout = 4_000;
701+
const server = Hapi.server({
702+
stoppingHandler: (_, res) => {
703+
704+
return res.response('server is shutting down').code(503);
705+
}
706+
});
707+
server.route({
708+
method: 'get', path: '/', handler: async (_, res) => {
709+
710+
await Hoek.wait(shutdownTimeout);
711+
return res.response('ok');
712+
}
713+
});
714+
await server.start();
715+
716+
const url = `http://localhost:${server.info.port}/`;
717+
718+
// Just performing one request to hold the server from immediately stopping.
719+
const firstRequest = Wreck.request('GET', url);
720+
721+
// Stop the server while the request is in progress
722+
await Hoek.wait(1_000);
723+
const timer = new Hoek.Bench();
724+
const stop = server.stop({ timeout: shutdownTimeout });
725+
726+
// Perform request after calling stop.
727+
await Hoek.wait(1_000);
728+
expect(server._core.phase).to.equal('stopping');
729+
const secondRequest = Wreck.request('GET', url);
730+
// const secondRequest = Http.get(url);
731+
expect(server._core.phase).to.equal('stopping');
732+
const responseToSecond = await secondRequest;
733+
expect(responseToSecond.statusCode).to.equal(503);
734+
await expect(Wreck.read(responseToSecond)).to.resolve('server is shutting down');
735+
expect(server._core.phase).to.equal('stopping');
736+
737+
const { statusCode } = await firstRequest;
738+
expect(statusCode).to.equal(200);
739+
expect(server._core.phase).to.equal('stopped');
740+
await stop;
741+
expect(timer.elapsed()).to.be.lessThan(shutdownTimeout);
742+
await expect(firstRequest).to.not.reject();
743+
});
623744
});
624745

625746
describe('_init()', () => {

0 commit comments

Comments
 (0)