Skip to content

Commit

Permalink
Add custom graceful shutdown handler
Browse files Browse the repository at this point in the history
  • Loading branch information
afharo committed Sep 26, 2024
1 parent 22377ee commit 52bb6d2
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 3 deletions.
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

0 comments on commit 52bb6d2

Please sign in to comment.