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

Add custom graceful shutdown handler #4528

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 6 additions & 3 deletions lib/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions lib/types/server/options.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
121 changes: 121 additions & 0 deletions test/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down
Loading