LibreChat/api/server/index.spec.js
Danny Avila 324584552c
⏱️ feat: Configurable HTTP Server Timeouts (#14481)
* http server config added

* Fix TypeScript compatibility by accepting NodeJS.ProcessEnv directly when applying optional HTTP server timeout configuration.

* fix(api): configure HTTP server timeouts for clustered workers

* 🕰️ fix: Warn When HTTP Timeouts Are Not Enforced

Codex review of the rebased contributor work surfaced two ways these settings
silently do nothing. Both reproduce, and neither was reported to the operator.

Bun accepts the four property assignments and reflects them back, but does not
enforce them: with keepAliveTimeout=100 and buffer=1000, Bun 1.3.13 held a
keep-alive connection past 3s where Node 24 closed it at 1101ms. Since `b:api`
runs the server under Bun, the existing info log confirmed a configuration that
was not in effect. Warn instead.

Node sweeps header/request timeouts on `connectionsCheckingInterval`, a
createServer option that `app.listen()` leaves at 30s, so sub-30s values round
up to it: headersTimeout=2000 returned 408 at 30004ms by default versus 2010ms
with a 250ms interval. Warn on values below the sweep interval rather than
restructure server construction, since every documented value and both Node
defaults already sit well above it. keepAliveTimeout is socket-driven and stays
exact, so it is excluded.

Both caveats documented in .env.example.

* 🩹 fix: Inject Runtime Versions Instead of Mutating `process.versions`

The spec deleted `process.versions.bun` to reset between cases, which failed
typecheck with TS2790: `@types/bun` is a packages/api dependency and augments
NodeJS.ProcessVersions with a required `bun: string`, so the property is not
optional and cannot be deleted. Assigning undefined would fail for the same
reason.

That augmentation also made the production check dishonest: TypeScript saw
`process.versions.bun` as always a string, so `!= null` read as a no-op branch
even though it is correct at runtime under Node.

Both resolved by taking runtime versions as a third injectable parameter,
matching the existing `environment` parameter. Callers in api/server are
unchanged, the narrow `{ bun?: string }` type restores honest narrowing, and
the tests no longer mutate global state, so they assert the same behavior
whether the suite runs under Node or `bun jest`.

* 📏 fix: Stop Claiming a Ceiling on Sweep-Delayed Timeouts

The warning added in 9adc3eb1c said the effective timeout is "up to 30000ms",
which promises a bound that does not hold. Node detects header/request expiry
only on the next connection sweep, so the delay is relative to the deadline
rather than capped by the interval: measured against the default 30s sweep, a
2000ms headersTimeout closed at 30004ms, 15x the configured value, and cases
where the timeout is near the interval did not fire within a 9s window at all.

Reworded to state the mechanism without asserting a ceiling, and to point
operators at values of 30000ms or above for predictable enforcement. Same
correction applied to the .env.example note, which claimed short values "round
up" to the interval.

*  fix: Clamp Headers Timeout to the Request Timeout

Setting only HTTP_REQUEST_TIMEOUT_MS below the 60s headers default left
headersTimeout > requestTimeout, a pairing createServer rejects outright with
ERR_OUT_OF_RANGE. Assigning the properties after construction skips that
validation, and the mismatch silently defeats the request timeout for a stalled
body: with requestTimeout=4000 and headersTimeout at its 60000 default, a client
that completed its headers and then stopped mid-body was still connected after
12s. Clamping headersTimeout to 4000 closes the same connection at 4017ms.

An earlier round dismissed this after testing partial *headers*, where
requestTimeout does evict on time. The gap only appears once headers are
complete and the body stalls, which is the case these timeouts exist to bound.

Mirrors Node's own rule rather than its constructor default: zero on either side
means disabled and is left alone, and an explicitly configured
HTTP_HEADERS_TIMEOUT_MS that conflicts is warned about before being clamped
instead of failing startup over a config typo.

---------

Co-authored-by: Peter Rothlaender <peter.rothlaender@ginkgo.com>
2026-07-28 09:10:17 -04:00

324 lines
12 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const request = require('supertest');
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
jest.mock('~/server/services/Config', () => ({
loadCustomConfig: jest.fn(() => Promise.resolve({})),
getAppConfig: jest.fn().mockResolvedValue({
paths: {
uploads: '/tmp',
dist: '/tmp/dist',
fonts: '/tmp/fonts',
assets: '/tmp/assets',
},
fileStrategy: 'local',
imageOutputType: 'PNG',
}),
setCachedTools: jest.fn(),
}));
jest.mock('~/app/clients/tools', () => ({
createOpenAIImageTools: jest.fn(() => []),
createYouTubeTools: jest.fn(() => []),
manifestToolMap: {},
toolkits: [],
}));
jest.mock('~/config', () => ({
createMCPServersRegistry: jest.fn(),
createMCPManager: jest.fn().mockResolvedValue({
getAppToolFunctions: jest.fn().mockResolvedValue({}),
}),
}));
jest.mock(
'@librechat/api/telemetry',
() => ({
initializeTelemetry: jest.fn(() => ({
enabled: false,
status: 'disabled',
shutdown: jest.fn(),
})),
telemetryMiddleware: jest.fn((_req, _res, next) => next()),
telemetryErrorMiddleware: jest.fn((err, _req, _res, next) => next(err)),
}),
{ virtual: true },
);
describe('Telemetry wiring', () => {
const source = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8');
it('loads telemetry before other server imports', () => {
const firstStatement = source
.split('\n')
.map((line) => line.trim())
.find(Boolean);
expect(firstStatement).toBe("const telemetry = require('./telemetry');");
});
it('mounts telemetry middleware after static assets and before routes', () => {
const telemetryMiddlewareIndex = source.indexOf('app.use(telemetry.telemetryMiddleware);');
const staticAssetsIndex = source.indexOf('app.use(staticCache(appConfig.paths.assets));');
const apiRoutesIndex = source.indexOf("app.use('/api/auth'");
expect(telemetryMiddlewareIndex).toBeGreaterThan(-1);
expect(staticAssetsIndex).toBeGreaterThan(-1);
expect(apiRoutesIndex).toBeGreaterThan(-1);
expect(staticAssetsIndex).toBeLessThan(telemetryMiddlewareIndex);
expect(telemetryMiddlewareIndex).toBeLessThan(apiRoutesIndex);
});
it('mounts telemetry error middleware before ErrorController', () => {
const telemetryErrorMiddlewareIndex = source.indexOf(
'app.use(telemetry.telemetryErrorMiddleware);',
);
const errorControllerIndex = source.indexOf('app.use(ErrorController);');
expect(telemetryErrorMiddlewareIndex).toBeGreaterThan(-1);
expect(errorControllerIndex).toBeGreaterThan(-1);
expect(telemetryErrorMiddlewareIndex).toBeLessThan(errorControllerIndex);
});
it('captures agent ingress before parsing and creates its recorder before auth routes', () => {
const ingressIndex = source.indexOf(
"app.use('/api/agents/chat', agentStartupIngressMiddleware);",
);
const jsonParserIndex = source.indexOf("app.use(express.json({ limit: '3mb' }));");
const recorderIndex = source.indexOf(
"app.use('/api/agents/chat', agentStartupTelemetryMiddleware);",
);
const tracingIndex = source.indexOf('app.use(telemetry.telemetryMiddleware);');
const agentsRouteIndex = source.indexOf("app.use('/api/agents', routes.agents);");
expect(ingressIndex).toBeGreaterThan(-1);
expect(recorderIndex).toBeGreaterThan(-1);
expect(ingressIndex).toBeLessThan(jsonParserIndex);
expect(tracingIndex).toBeLessThan(recorderIndex);
expect(recorderIndex).toBeLessThan(agentsRouteIndex);
});
});
describe('Startup readiness wiring', () => {
const source = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8');
it('configures generation streams before the server accepts requests', () => {
const streamConfigIndex = source.indexOf('configureGenerationStreams();');
const listenIndex = source.indexOf('const server = app.listen');
const postListenMcpIndex = source.indexOf('await initializeMCPs();');
expect(streamConfigIndex).toBeGreaterThan(-1);
expect(listenIndex).toBeGreaterThan(-1);
expect(postListenMcpIndex).toBeGreaterThan(-1);
expect(streamConfigIndex).toBeLessThan(listenIndex);
expect(streamConfigIndex).toBeLessThan(postListenMcpIndex);
});
it('registers generation stream cleanup with the graceful shutdown coordinator', () => {
const shutdownRegistrationIndex = source.indexOf(
"registerShutdownTask('generation job manager'",
);
const listenIndex = source.indexOf('const server = app.listen');
expect(shutdownRegistrationIndex).toBeGreaterThan(-1);
expect(shutdownRegistrationIndex).toBeLessThan(listenIndex);
});
it('configures HTTP timeouts before graceful shutdown handling', () => {
const listenIndex = source.indexOf('const server = app.listen');
const timeoutConfigIndex = source.indexOf('configureServerTimeouts(server);');
const shutdownIndex = source.indexOf('setupGracefulShutdown(server);');
expect(listenIndex).toBeGreaterThan(-1);
expect(timeoutConfigIndex).toBeGreaterThan(-1);
expect(shutdownIndex).toBeGreaterThan(-1);
expect(listenIndex).toBeLessThan(timeoutConfigIndex);
expect(timeoutConfigIndex).toBeLessThan(shutdownIndex);
});
it('mounts the chat-start readiness gate before agent routes', () => {
const readinessGateIndex = source.indexOf(
"app.use('/api/agents/chat', rejectChatStartsUntilReady);",
);
const agentsRouteIndex = source.indexOf("app.use('/api/agents', routes.agents);");
expect(readinessGateIndex).toBeGreaterThan(-1);
expect(agentsRouteIndex).toBeGreaterThan(-1);
expect(readinessGateIndex).toBeLessThan(agentsRouteIndex);
});
});
describe('Server Configuration', () => {
// Increase the default timeout to allow for Mongo cleanup
jest.setTimeout(30_000);
let mongoServer;
let app;
/** Mocked fs.readFileSync for index.html */
const originalReadFileSync = fs.readFileSync;
beforeAll(() => {
fs.readFileSync = function (filepath, options) {
if (filepath.includes('index.html')) {
return '<!DOCTYPE html><html><head><title>LibreChat</title></head><body><div id="root"></div></body></html>';
}
return originalReadFileSync(filepath, options);
};
});
afterAll(() => {
// Restore original fs.readFileSync
fs.readFileSync = originalReadFileSync;
});
beforeAll(async () => {
// Create the required directories and files for the test
const fs = require('fs');
const path = require('path');
const dirs = ['/tmp/dist', '/tmp/fonts', '/tmp/assets'];
dirs.forEach((dir) => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
fs.writeFileSync(
path.join('/tmp/dist', 'index.html'),
'<!DOCTYPE html><html><head><title>LibreChat</title></head><body><div id="root"></div></body></html>',
);
mongoServer = await MongoMemoryServer.create();
process.env.MONGO_URI = mongoServer.getUri();
process.env.PORT = '0'; // Use a random available port
app = require('~/server');
// Wait for the app to be healthy
await healthCheckPoll(app);
});
afterAll(async () => {
await mongoServer.stop();
await mongoose.disconnect();
});
it('should return OK for /health', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.text).toBe('OK');
});
it('should not cache index page', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
expect(response.headers['pragma']).toBe('no-cache');
expect(response.headers['expires']).toBe('0');
});
it('should return 404 JSON for undefined API routes', async () => {
const response = await request(app).get('/api/nonexistent');
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Endpoint not found' });
});
it('should return 404 JSON for nested undefined API routes', async () => {
const response = await request(app).get('/api/nonexistent/nested/path');
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Endpoint not found' });
});
it('should return 404 JSON for non-GET methods on undefined API routes', async () => {
const post = await request(app).post('/api/nonexistent');
expect(post.status).toBe(404);
expect(post.body).toEqual({ message: 'Endpoint not found' });
const del = await request(app).delete('/api/nonexistent');
expect(del.status).toBe(404);
expect(del.body).toEqual({ message: 'Endpoint not found' });
});
it('should return 404 JSON for the /api root path', async () => {
const response = await request(app).get('/api');
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Endpoint not found' });
});
it('should serve SPA HTML for non-API unmatched routes', async () => {
const response = await request(app).get('/this/does/not/exist');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toMatch(/html/);
});
it('should gate React Query Devtools config in SPA HTML by debug header', async () => {
const defaultResponse = await request(app).get('/this/does/not/exist');
const debugResponse = await request(app)
.get('/this/does/not/exist')
.set('x-librechat-enable-query-devtools', '1');
const directIndexResponse = await request(app)
.get('/index.html')
.set('x-librechat-enable-query-devtools', '1');
expect(defaultResponse.status).toBe(200);
expect(defaultResponse.headers.vary).toContain('x-librechat-enable-query-devtools');
expect(defaultResponse.text).not.toContain('enableQueryDevtools');
expect(debugResponse.status).toBe(200);
expect(debugResponse.headers.vary).toContain('x-librechat-enable-query-devtools');
expect(debugResponse.text).toContain('window.__LIBRECHAT_CONFIG__');
expect(debugResponse.text).toContain('data-librechat-query-devtools="true"');
expect(debugResponse.text).toContain('"enableQueryDevtools":true');
expect(directIndexResponse.status).toBe(200);
expect(directIndexResponse.headers.vary).toContain('x-librechat-enable-query-devtools');
expect(directIndexResponse.text).toContain('window.__LIBRECHAT_CONFIG__');
expect(directIndexResponse.text).toContain('data-librechat-query-devtools="true"');
expect(directIndexResponse.text).toContain('"enableQueryDevtools":true');
});
it('should return 500 for unknown errors via ErrorController', async () => {
// Testing the error handling here on top of unit tests to ensure the middleware is correctly integrated
// Mock MongoDB operations to fail
const originalFindOne = mongoose.models.User.findOne;
const mockError = new Error('MongoDB operation failed');
mongoose.models.User.findOne = jest.fn().mockImplementation(() => {
throw mockError;
});
try {
const response = await request(app).post('/api/auth/login').send({
email: 'test@example.com',
password: 'password123',
});
expect(response.status).toBe(500);
expect(response.text).toBe('An unknown error occurred.');
} finally {
// Restore original function
mongoose.models.User.findOne = originalFindOne;
}
});
});
// Polls the /health endpoint every 30ms for up to 10 seconds to wait for the server to start completely
async function healthCheckPoll(app, retries = 0) {
const maxRetries = Math.floor(10000 / 30); // 10 seconds / 30ms
try {
const response = await request(app).get('/health');
if (response.status === 200) {
return; // App is healthy
}
} catch {
// Ignore connection errors during polling
}
if (retries < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 30));
await healthCheckPoll(app, retries + 1);
} else {
throw new Error('App did not become healthy within 10 seconds.');
}
}