diff --git a/docs/backend-system/core-services/root-http-router.md b/docs/backend-system/core-services/root-http-router.md index 0ced7aac66..1bda95a990 100644 --- a/docs/backend-system/core-services/root-http-router.md +++ b/docs/backend-system/core-services/root-http-router.md @@ -67,12 +67,18 @@ backend: serverShutdownDelay: { seconds: 20 } server: # (Optional) HTTP server configuration, Node.js defaults apply otherwise + # Timeout values support multiple formats: + # - Numbers (milliseconds): 30000 + # - Duration strings: '30s', '1 minute', '2 hours' + # - ISO duration strings: 'PT30S', 'PT1M', 'PT2H' + # - Duration objects: { seconds: 30 }, { minutes: 1 }, { hours: 2 } headersTimeout: 60000 - keepAliveTimeout: 5000 + requestTimeout: '30s' + keepAliveTimeout: { seconds: 5 } + timeout: 'PT30S' + # Numeric-only settings maxHeadersCount: 2000 maxRequestsPerSocket: 100 - requestTimeout: 30000 - timeout: 30000 ``` ### Via Code diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 2982557f32..f0632a37bb 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -95,6 +95,31 @@ export interface Config { }; }; + /** + * Server-level HTTP options configuration for the backend. + * These options are passed directly to the underlying Node.js HTTP server. + * + * Timeout values support multiple formats: + * - A number in milliseconds + * - A string in the format of '1d', '2 seconds' etc. as supported by the `ms` library + * - A standard ISO formatted duration string, e.g. 'P2DT6H' or 'PT1M' + * - An object with individual units (in plural) as keys, e.g. `{ days: 2, hours: 6 }` + */ + server?: { + /** Sets the timeout value for receiving the complete HTTP headers from the client. */ + headersTimeout?: number | string | HumanDuration; + /** Sets the timeout value for receiving the entire request (headers and body) from the client. */ + requestTimeout?: number | string | HumanDuration; + /** Sets the timeout value for inactivity on a socket during keep-alive. */ + keepAliveTimeout?: number | string | HumanDuration; + /** Sets the timeout value for sockets. */ + timeout?: number | string | HumanDuration; + /** Limits maximum incoming headers count. */ + maxHeadersCount?: number; + /** Sets the maximum number of requests socket can handle before closing keep alive connection. */ + maxRequestsPerSocket?: number; + }; + /** * Options used by the default auditor service. */ diff --git a/packages/backend-defaults/report-rootHttpRouter.api.md b/packages/backend-defaults/report-rootHttpRouter.api.md index acabea9640..84424be39d 100644 --- a/packages/backend-defaults/report-rootHttpRouter.api.md +++ b/packages/backend-defaults/report-rootHttpRouter.api.md @@ -82,14 +82,6 @@ export type HttpServerOptions = { https?: { certificate: HttpServerCertificateOptions; }; - serverOptions?: { - headersTimeout?: number; - requestTimeout?: number; - keepAliveTimeout?: number; - timeout?: number; - maxHeadersCount?: number; - maxRequestsPerSocket?: number; - }; }; // @public diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/config.test.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/config.test.ts index 0fa9dc0420..577bae08f3 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/config.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/config.test.ts @@ -61,59 +61,6 @@ describe('readHttpServerOptions', () => { expect(readHttpServerOptions(new ConfigReader(input))).toEqual(output); }); - it.each([ - [ - { - server: { - headersTimeout: 10000, - requestTimeout: 30000, - keepAliveTimeout: 5000, - timeout: 60000, - maxHeadersCount: 1000, - maxRequestsPerSocket: 100, - }, - }, - { - listen: { host: '', port: 7007 }, - https: undefined, - serverOptions: { - headersTimeout: 10000, - requestTimeout: 30000, - keepAliveTimeout: 5000, - timeout: 60000, - maxHeadersCount: 1000, - maxRequestsPerSocket: 100, - }, - }, - ], - [ - { - server: { - keepAliveTimeout: 8000, - timeout: 30000, - }, - }, - { - listen: { host: '', port: 7007 }, - https: undefined, - serverOptions: { - keepAliveTimeout: 8000, - timeout: 30000, - }, - }, - ], - [ - { server: {} }, - { - listen: { host: '', port: 7007 }, - https: undefined, - serverOptions: undefined, - }, - ], - ])('should read server options %#', (input, output) => { - expect(readHttpServerOptions(new ConfigReader(input))).toEqual(output); - }); - it.each([ [ { listen: { port: 'not-a-number' } }, diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/config.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/config.ts index 229ba2d78d..ec29a8a044 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/config.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/config.ts @@ -38,7 +38,6 @@ export function readHttpServerOptions(config?: Config): HttpServerOptions { return { listen: readHttpListenOptions(config), https: readHttpsOptions(config), - serverOptions: readServerOptions(config), }; } @@ -100,32 +99,3 @@ function readHttpsOptions(config?: Config): HttpServerOptions['https'] { }, }; } - -function readServerOptions( - config?: Config, -): HttpServerOptions['serverOptions'] { - const serverConfig = config?.getOptionalConfig('server'); - if (!serverConfig) { - return undefined; - } - - const serverOptions: HttpServerOptions['serverOptions'] = {}; - - const keys = [ - 'headersTimeout', - 'requestTimeout', - 'keepAliveTimeout', - 'timeout', - 'maxHeadersCount', - 'maxRequestsPerSocket', - ] as const; - - for (const key of keys) { - const value = serverConfig.getOptionalNumber(key); - if (value !== undefined) { - serverOptions[key] = value; - } - } - - return Object.keys(serverOptions).length === 0 ? undefined : serverOptions; -} diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/createHttpServer.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/createHttpServer.ts index 1ae6cc4fd1..fbb6ac5805 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/createHttpServer.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/createHttpServer.ts @@ -84,8 +84,6 @@ async function createServer( options: HttpServerOptions, deps: { logger: LoggerService }, ): Promise { - let server: http.Server; - if (options.https) { const { certificate } = options.https; if (certificate.type === 'generated') { @@ -93,31 +91,10 @@ async function createServer( certificate.hostname, deps.logger, ); - server = https.createServer(credentials, listener); - } else { - server = https.createServer(certificate, listener); + return https.createServer(credentials, listener); } - } else { - server = http.createServer(listener); + return https.createServer(certificate, listener); } - // apply custom server options - if (options.serverOptions) { - const { serverOptions } = options; - - if (serverOptions.headersTimeout !== undefined) - server.headersTimeout = serverOptions.headersTimeout; - if (serverOptions.requestTimeout !== undefined) - server.requestTimeout = serverOptions.requestTimeout; - if (serverOptions.keepAliveTimeout !== undefined) - server.keepAliveTimeout = serverOptions.keepAliveTimeout; - if (serverOptions.timeout !== undefined) - server.timeout = serverOptions.timeout; - if (serverOptions.maxHeadersCount !== undefined) - server.maxHeadersCount = serverOptions.maxHeadersCount; - if (serverOptions.maxRequestsPerSocket !== undefined) - server.maxRequestsPerSocket = serverOptions.maxRequestsPerSocket; - } - - return server; + return http.createServer(listener); } diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/types.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/types.ts index 13e7914c03..2298ef11ff 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/types.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/types.ts @@ -42,14 +42,6 @@ export type HttpServerOptions = { https?: { certificate: HttpServerCertificateOptions; }; - serverOptions?: { - headersTimeout?: number; - requestTimeout?: number; - keepAliveTimeout?: number; - timeout?: number; - maxHeadersCount?: number; - maxRequestsPerSocket?: number; - }; }; /** diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.test.ts index 0f2b6463e4..8f8d1b7656 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.test.ts @@ -323,4 +323,65 @@ describe('rootHttpRouterServiceFactory', () => { }), ).resolves.toBeUndefined(); }); + + it('should start successfully with server configuration options', async () => { + const { app } = await createExpressApp( + mockServices.rootConfig.factory({ + data: { + backend: { + listen: { port: 0 }, + server: { + headersTimeout: 30000, + requestTimeout: '45s', + keepAliveTimeout: 'PT1M', + timeout: { seconds: 120 }, + maxHeadersCount: 2000, + maxRequestsPerSocket: 100, + }, + }, + }, + }), + ); + + // Verify the server starts and responds to health checks + await request(app) + .get('/.backstage/health/v1/liveness') + .expect(200, { status: 'ok' }); + }); + + it('should start successfully with partial server configuration', async () => { + const { app } = await createExpressApp( + mockServices.rootConfig.factory({ + data: { + backend: { + listen: { port: 0 }, + server: { + headersTimeout: 60000, + maxHeadersCount: 1500, + }, + }, + }, + }), + ); + + await request(app) + .get('/.backstage/health/v1/liveness') + .expect(200, { status: 'ok' }); + }); + + it('should start successfully with no server configuration', async () => { + const { app } = await createExpressApp( + mockServices.rootConfig.factory({ + data: { + backend: { + listen: { port: 0 }, + }, + }, + }), + ); + + await request(app) + .get('/.backstage/health/v1/liveness') + .expect(200, { status: 'ok' }); + }); }); diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts index 3b50515742..1bbf986a98 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts @@ -117,6 +117,83 @@ const rootHttpRouterServiceFactoryWithOptions = ( if (trustProxy !== undefined) { app.set('trust proxy', trustProxy); } + + // Apply server-level HTTP options from config + const backendConfig = config.getOptionalConfig('backend'); + const serverConfig = backendConfig?.getOptionalConfig('server'); + + if (serverConfig) { + // Helper function to read duration values (supporting number, string, or HumanDuration) + const readDurationValue = (key: string): number | undefined => { + if (!serverConfig.has(key)) { + return undefined; + } + + const value = serverConfig.getOptional(key); + if (typeof value === 'number') { + return value; + } + + // If it's not a number, try to read it as a duration + try { + const duration = readDurationFromConfig(serverConfig, { key }); + return durationToMilliseconds(duration); + } catch (error) { + // Log warning for parsing failures + logger.warn( + `Failed to parse backend.server.${key} as duration: ${error}. ` + + `Expected a number (milliseconds), duration string (e.g., '30s'), ` + + `ISO duration (e.g., 'PT30S'), or duration object (e.g., {seconds: 30}). ` + + `Falling back to number parsing.`, + ); + // Fallback to reading as number if duration parsing fails + const fallbackValue = serverConfig.getOptionalNumber(key); + if (fallbackValue === undefined && typeof value === 'string') { + logger.error( + `backend.server.${key} value '${value}' could not be parsed as either ` + + `a duration or a number. This setting will be ignored.`, + ); + } + return fallbackValue; + } + }; + + // Apply timeout settings + const headersTimeout = readDurationValue('headersTimeout'); + if (headersTimeout !== undefined) { + server.headersTimeout = headersTimeout; + } + + const requestTimeout = readDurationValue('requestTimeout'); + if (requestTimeout !== undefined) { + server.requestTimeout = requestTimeout; + } + + const keepAliveTimeout = readDurationValue('keepAliveTimeout'); + if (keepAliveTimeout !== undefined) { + server.keepAliveTimeout = keepAliveTimeout; + } + + const timeout = readDurationValue('timeout'); + if (timeout !== undefined) { + server.timeout = timeout; + } + + // Apply numeric settings + const maxHeadersCount = + serverConfig.getOptionalNumber('maxHeadersCount'); + if (maxHeadersCount !== undefined) { + server.maxHeadersCount = maxHeadersCount; + } + + const maxRequestsPerSocket = serverConfig.getOptionalNumber( + 'maxRequestsPerSocket', + ); + if (maxRequestsPerSocket !== undefined) { + server.maxRequestsPerSocket = maxRequestsPerSocket; + } + } + app.use(middleware.helmet()); app.use(middleware.cors()); app.use(middleware.compression());