Merge pull request #30483 from BethGriggs/declarative-http-options

feat(http): support server-level timeout and socket options via app-c…
This commit is contained in:
Fredrik Adelöw
2025-09-23 16:54:04 +02:00
committed by GitHub
5 changed files with 180 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/backend-defaults': minor
---
Adds support for configuring server-level HTTP options through the
`app-config.yaml` file under the `backend.server` key. Supported options
include `headersTimeout`, `keepAliveTimeout`, `requestTimeout`, `timeout`,
`maxHeadersCount`, and `maxRequestsPerSocket`.
These are passed directly to the underlying Node.js HTTP server.
If omitted, Node.js defaults are used.
@@ -65,6 +65,20 @@ backend:
# - 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 }`.
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
requestTimeout: '30s'
keepAliveTimeout: { seconds: 5 }
timeout: 'PT30S'
# Numeric-only settings
maxHeadersCount: 2000
maxRequestsPerSocket: 100
```
### Via Code
+25
View File
@@ -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.
*/
@@ -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' });
});
});
@@ -117,6 +117,75 @@ 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.`,
);
return undefined;
}
};
// 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());