feat(common,express): add graceful shutdown option

Closes #11416
This commit is contained in:
Himanshu Gupta
2026-01-05 00:35:14 +05:30
parent e8768e77dc
commit 319437ac3f
6 changed files with 155 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import { INestApplication } from '@nestjs/common';
import { expect } from 'chai';
import * as http from 'http';
import { AppModule } from '../src/app.module';
describe('Graceful Shutdown (Express)', () => {
let app: INestApplication;
afterEach(async () => {
if (app) {
await app.close();
}
});
it('should allow in-flight requests to complete when gracefulShutdown is enabled', async () => {
app = await NestFactory.create(
AppModule,
new ExpressAdapter() as any,
{
gracefulShutdown: true,
logger: false,
} as any,
);
await app.listen(0);
const port = app.getHttpServer().address().port;
const requestPromise = new Promise<string>((resolve, reject) => {
http
.get(`http://localhost:${port}/slow`, res => {
let data = '';
res.on('data', c => (data += c));
res.on('end', () => resolve(data));
})
.on('error', reject);
});
// Wait to ensure request is processing
await new Promise(r => setTimeout(r, 100));
const closePromise = app.close();
// The in-flight request should finish successfully
const response = await requestPromise;
expect(response).to.equal('ok');
await closePromise;
});
it('should return 503 for NEW queued requests on existing connections during shutdown', async () => {
app = await NestFactory.create(
AppModule,
new ExpressAdapter() as any,
{
gracefulShutdown: true,
logger: false,
} as any,
);
await app.listen(0);
const port = app.getHttpServer().address().port;
// Force 1 socket to ensure queuing/reuse
const agent = new http.Agent({ keepAlive: true, maxSockets: 1 });
// 1. Send Request A (slow) - occupies the socket
const req1 = http.get(`http://localhost:${port}/slow`, { agent });
// 2. Wait so Request A is definitely "in flight"
await new Promise(r => setTimeout(r, 100));
// 3. Trigger Shutdown (don't await yet)
const closePromise = app.close();
// Give NestJS a moment to set the isShuttingDown flag
await new Promise(r => setTimeout(r, 10));
// 4. Send Request B immediately using the same agent.
const statusPromise = new Promise<number>((resolve, reject) => {
const req = http.get(`http://localhost:${port}/slow`, { agent }, res => {
resolve(res.statusCode || 0);
});
req.on('error', reject);
});
// 5. Cleanup Request A
req1.on('error', () => {});
const status = await statusPromise;
expect(status).to.equal(503);
await closePromise;
agent.destroy();
});
});

View File

@@ -0,0 +1,11 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class AppController {
@Get('slow')
async slow() {
// Simulate work
await new Promise(resolve => setTimeout(resolve, 500));
return 'ok';
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
@Module({
controllers: [AppController],
})
export class AppModule {}

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"baseUrl": ".",
"paths": {
"@nestjs/common": ["../../packages/common/index.ts"],
"@nestjs/core": ["../../packages/core/index.ts"],
"@nestjs/platform-express": ["../../packages/platform-express/index.ts"],
"@nestjs/testing": ["../../packages/testing/index.ts"]
}
},
"include": ["e2e/**/*.ts", "src/**/*.ts"]
}

View File

@@ -30,4 +30,11 @@ export interface NestApplicationOptions extends NestApplicationContextOptions {
* keep-alive connections in the HTTP adapter.
*/
forceCloseConnections?: boolean;
/**
* Whether to enable graceful shutdown behavior.
* When enabled, the server will return 503 Service Unavailable for new requests
* during the shutdown process, but allow existing in-flight requests to complete.
* @default false
*/
gracefulShutdown?: boolean;
}

View File

@@ -55,6 +55,7 @@ export class ExpressAdapter extends AbstractHttpAdapter<
private readonly logger = new Logger(ExpressAdapter.name);
private readonly openConnections = new Set<Duplex>();
private readonly registeredPrefixes = new Set<string>();
private isShuttingDown = false;
private onRequestHook?: (
req: express.Request,
res: express.Response,
@@ -243,6 +244,7 @@ export class ExpressAdapter extends AbstractHttpAdapter<
}
public close() {
this.isShuttingDown = true;
this.closeOpenConnections();
if (!this.httpServer) {
@@ -317,6 +319,7 @@ export class ExpressAdapter extends AbstractHttpAdapter<
}
public initHttpServer(options: NestApplicationOptions) {
this.logger.log('[DEBUG] initHttpServer called with:', options);
const isHttpsEnabled = options && options.httpsOptions;
if (isHttpsEnabled) {
this.httpServer = https.createServer(
@@ -327,6 +330,22 @@ export class ExpressAdapter extends AbstractHttpAdapter<
this.httpServer = http.createServer(this.getInstance());
}
if (options?.gracefulShutdown) {
this.logger.log('[DEBUG] Registering graceful shutdown middleware');
this.instance.use((req: any, res: any, next: any) => {
this.logger.log(
`[DEBUG] Middleware hit. isShuttingDown: ${this.isShuttingDown}`,
);
if (this.isShuttingDown) {
this.logger.log('🛑 Middleware Intercepted Request! Sending 503...');
res.set('Connection', 'close');
res.status(503).send('Service Unavailable');
} else {
next();
}
});
}
if (options?.forceCloseConnections) {
this.trackOpenConnections();
}