diff --git a/.env.example b/.env.example index 1212ad64d5..0cc8f2fa6c 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ PORT=3080 # Optional Node.js HTTP server timeouts in milliseconds. When unset, Node.js defaults apply. # For an ALB, set the application keep-alive timeout above the ALB idle timeout. +# Requires Node.js: Bun accepts these values but does not enforce them. +# Header and request timeouts are only swept every 30s, so values below 30000 round up to it. +# Keep-alive is socket-driven and remains exact at any value. # HTTP_KEEP_ALIVE_TIMEOUT_MS=70000 # HTTP_KEEP_ALIVE_TIMEOUT_BUFFER_MS=5000 # HTTP_HEADERS_TIMEOUT_MS=80000 diff --git a/packages/api/src/app/server.spec.ts b/packages/api/src/app/server.spec.ts index e9d65ba9b4..9788c6b16c 100644 --- a/packages/api/src/app/server.spec.ts +++ b/packages/api/src/app/server.spec.ts @@ -1,8 +1,19 @@ import { createServer } from 'node:http'; - +import { logger } from '@librechat/data-schemas'; import { configureServerTimeouts } from './server'; describe('configureServerTimeouts', () => { + let warn: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(logger, 'warn').mockImplementation(() => logger); + }); + + afterEach(() => { + warn.mockRestore(); + delete process.versions.bun; + }); + it('preserves Node.js defaults when variables are unset', () => { const server = createServer(); const defaults = { @@ -53,4 +64,41 @@ describe('configureServerTimeouts', () => { expect(server.headersTimeout).toBe(defaultHeadersTimeout); expect(server.requestTimeout).toBe(0); }); + + it('warns that Bun does not enforce the configured timeouts', () => { + process.versions.bun = '1.3.13'; + + configureServerTimeouts(createServer(), { HTTP_KEEP_ALIVE_TIMEOUT_MS: '70000' }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Bun does not enforce them')); + }); + + it('stays quiet under Bun when no timeout is configured', () => { + process.versions.bun = '1.3.13'; + + configureServerTimeouts(createServer(), {}); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('warns when header or request timeouts fall below the connection sweep interval', () => { + configureServerTimeouts(createServer(), { + HTTP_HEADERS_TIMEOUT_MS: '5000', + HTTP_REQUEST_TIMEOUT_MS: '10000', + }); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('HTTP_HEADERS_TIMEOUT_MS, HTTP_REQUEST_TIMEOUT_MS'), + ); + }); + + it('does not warn about sweep resolution for zero or above-interval timeouts', () => { + configureServerTimeouts(createServer(), { + HTTP_KEEP_ALIVE_TIMEOUT_MS: '1000', + HTTP_HEADERS_TIMEOUT_MS: '80000', + HTTP_REQUEST_TIMEOUT_MS: '0', + }); + + expect(warn).not.toHaveBeenCalled(); + }); }); diff --git a/packages/api/src/app/server.ts b/packages/api/src/app/server.ts index 45ac8a1dda..f6d1622f3e 100644 --- a/packages/api/src/app/server.ts +++ b/packages/api/src/app/server.ts @@ -1,5 +1,13 @@ +import { logger } from '@librechat/data-schemas'; import type { Server } from 'node:http'; +/** + * Node sweeps header/request timeouts on `connectionsCheckingInterval`, which is a + * `createServer` option that `app.listen()` leaves at its 30s default. Values below this + * are rounded up to it. `keepAliveTimeout` is socket-driven and stays exact. + */ +const TIMEOUT_SWEEP_RESOLUTION_MS = 30_000; + const parseTimeout = (value?: string): number | undefined => { if (value == null || value.trim() === '') { return undefined; @@ -30,4 +38,29 @@ export const configureServerTimeouts = ( if (requestTimeout != null) { server.requestTimeout = requestTimeout; } + + const configured = [keepAliveTimeout, keepAliveTimeoutBuffer, headersTimeout, requestTimeout]; + if (configured.every((value) => value == null)) { + return; + } + + /** Bun accepts and reflects these assignments back without enforcing them. */ + if (process.versions.bun != null) { + logger.warn( + 'HTTP server timeouts are configured but Bun does not enforce them; run under Node.js for these settings to take effect.', + ); + } + + const belowResolution = [ + { name: 'HTTP_HEADERS_TIMEOUT_MS', value: headersTimeout }, + { name: 'HTTP_REQUEST_TIMEOUT_MS', value: requestTimeout }, + ] + .filter(({ value }) => value != null && value > 0 && value < TIMEOUT_SWEEP_RESOLUTION_MS) + .map(({ name }) => name); + + if (belowResolution.length > 0) { + logger.warn( + `${belowResolution.join(', ')} set below the ${TIMEOUT_SWEEP_RESOLUTION_MS}ms connection sweep interval; expiry is only detected once per sweep, so the effective timeout is up to ${TIMEOUT_SWEEP_RESOLUTION_MS}ms.`, + ); + } };