Skip to content
Open
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
102 changes: 102 additions & 0 deletions integration/graceful-shutdown/e2e/express.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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`,
{
// Explicitly close connection after response to speed up server shutdown
headers: { Connection: 'close' },
},
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;
}).timeout(10000);

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, 50));

// 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();
}).timeout(10000);
});
11 changes: 11 additions & 0 deletions integration/graceful-shutdown/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
7 changes: 7 additions & 0 deletions integration/graceful-shutdown/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';

@Module({
controllers: [AppController],
})
export class AppModule {}
18 changes: 18 additions & 0 deletions integration/graceful-shutdown/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"strict": true,
"esModuleInterop": true,
"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": ["src/**/*", "e2e/**/*"],
"exclude": ["node_modules"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
13 changes: 13 additions & 0 deletions packages/platform-express/adapters/express-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export class ExpressAdapter extends AbstractHttpAdapter<
private readonly routerMethodFactory = new RouterMethodFactory();
private readonly logger = new Logger(ExpressAdapter.name);
private readonly openConnections = new Set<Duplex>();
private isShuttingDown = false;
private onRequestHook?: (
req: express.Request,
res: express.Response,
Expand Down Expand Up @@ -214,6 +215,7 @@ export class ExpressAdapter extends AbstractHttpAdapter<
}

public close() {
this.isShuttingDown = true;
this.closeOpenConnections();

if (!this.httpServer) {
Expand Down Expand Up @@ -298,6 +300,17 @@ export class ExpressAdapter extends AbstractHttpAdapter<
this.httpServer = http.createServer(this.getInstance());
}

if (options?.gracefulShutdown) {
this.instance.use((req: any, res: any, next: any) => {
if (this.isShuttingDown) {
res.set('Connection', 'close');
res.status(503).send('Service Unavailable');
} else {
next();
}
});
}

if (options?.forceCloseConnections) {
this.trackOpenConnections();
}
Expand Down
Loading