feat(request-body): implement custom JSON body parser and error handler
This commit is contained in:
parent
6d78e5198c
commit
f8c603fafb
5 changed files with 128 additions and 9 deletions
|
|
@ -12,6 +12,7 @@ import { requireAdminAccess, requireSessionCsrf } from './utils/adminAuth';
|
|||
import { logger } from './utils/logger';
|
||||
import { getUtcTimestamp } from './utils/time';
|
||||
import { ModelCatalogService } from './services/ModelCatalogService';
|
||||
import { createJsonBodyParser, JSON_BODY_LIMIT, requestBodyErrorHandler } from './utils/requestBody';
|
||||
|
||||
const envPathCandidates = [
|
||||
path.resolve(__dirname, '..', '..', '.env'),
|
||||
|
|
@ -43,9 +44,8 @@ export function createServer(): Application {
|
|||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json({
|
||||
limit: '30mb',
|
||||
}));
|
||||
app.use(createJsonBodyParser());
|
||||
app.use(requestBodyErrorHandler);
|
||||
|
||||
app.use('/admin/auth', adminAuthRoutes);
|
||||
app.use('/admin/analytics', requireAdminAccess, requireSessionCsrf, analyticsRoutes);
|
||||
|
|
|
|||
82
server/src/utils/requestBody.ts
Normal file
82
server/src/utils/requestBody.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import express, { ErrorRequestHandler, RequestHandler } from 'express';
|
||||
import { logger } from './logger';
|
||||
|
||||
export const JSON_BODY_LIMIT = '30mb';
|
||||
|
||||
interface BodyParserError extends Error {
|
||||
type?: string;
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
limit?: number;
|
||||
length?: number;
|
||||
expected?: number;
|
||||
received?: number;
|
||||
}
|
||||
|
||||
function parseContentLength(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function firstNumber(...values: Array<number | undefined>): number | undefined {
|
||||
return values.find((value): value is number => typeof value === 'number' && Number.isFinite(value));
|
||||
}
|
||||
|
||||
export function formatByteSize(bytes: number | undefined): string {
|
||||
if (bytes === undefined) return 'unknown';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
||||
const units = ['KB', 'MB', 'GB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]} (${bytes} B)`;
|
||||
}
|
||||
|
||||
export function createJsonBodyParser(limit: string = JSON_BODY_LIMIT): RequestHandler {
|
||||
return express.json({ limit });
|
||||
}
|
||||
|
||||
export const requestBodyErrorHandler: ErrorRequestHandler = (err: BodyParserError, req, res, next) => {
|
||||
const status = err.status ?? err.statusCode;
|
||||
const isPayloadTooLarge = err.type === 'entity.too.large' || status === 413;
|
||||
const isBodyParserClientError = typeof err.type === 'string' && status !== undefined && status >= 400 && status < 500;
|
||||
|
||||
if (isPayloadTooLarge) {
|
||||
const contentLengthBytes = parseContentLength(req.get('content-length'));
|
||||
const payloadBytes = firstNumber(err.length, err.received, err.expected, contentLengthBytes);
|
||||
const limitBytes = firstNumber(err.limit);
|
||||
|
||||
logger.warn(
|
||||
`Request body too large: ${req.method} ${req.originalUrl || req.url} ` +
|
||||
`payload=${formatByteSize(payloadBytes)} limit=${formatByteSize(limitBytes)} ` +
|
||||
`content-length=${formatByteSize(contentLengthBytes)}`,
|
||||
{
|
||||
content_length_bytes: contentLengthBytes,
|
||||
payload_size_bytes: payloadBytes,
|
||||
parser_limit_bytes: limitBytes,
|
||||
parser_error_type: err.type,
|
||||
}
|
||||
);
|
||||
|
||||
res.status(413).json({
|
||||
error: 'Request body too large',
|
||||
payload_size_bytes: payloadBytes,
|
||||
limit_bytes: limitBytes,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBodyParserClientError) {
|
||||
logger.warn(`Invalid request body: ${req.method} ${req.originalUrl || req.url} (${err.type})`);
|
||||
res.status(status).json({ error: err.message || 'Invalid request body' });
|
||||
return;
|
||||
}
|
||||
|
||||
next(err);
|
||||
};
|
||||
37
server/tests/unit/request-body.test.ts
Normal file
37
server/tests/unit/request-body.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createJsonBodyParser, requestBodyErrorHandler } from '../../src/utils/requestBody';
|
||||
import { logger } from '../../src/utils/logger';
|
||||
|
||||
describe('request body parser errors', () => {
|
||||
it('should log payload size details when JSON body exceeds the configured limit', async () => {
|
||||
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
const app = express();
|
||||
|
||||
app.use(createJsonBodyParser('1kb'));
|
||||
app.use(requestBodyErrorHandler);
|
||||
app.post('/v1/chat/completions', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.send({ model: 'vision-test-model', data: 'x'.repeat(2048) });
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(response.body.error).toBe('Request body too large');
|
||||
expect(response.body.payload_size_bytes).toBeGreaterThan(1024);
|
||||
expect(response.body.limit_bytes).toBe(1024);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy.mock.calls[0][0]).toContain('Request body too large');
|
||||
expect(warnSpy.mock.calls[0][0]).toContain('payload=');
|
||||
expect(warnSpy.mock.calls[0][0]).toContain('limit=1.00 KB (1024 B)');
|
||||
expect(warnSpy.mock.calls[0][1]).toMatchObject({
|
||||
payload_size_bytes: expect.any(Number),
|
||||
parser_limit_bytes: 1024,
|
||||
parser_error_type: 'entity.too.large',
|
||||
});
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import express from 'express';
|
||||
import { createJsonBodyParser, requestBodyErrorHandler } from '../../src/utils/requestBody';
|
||||
|
||||
export interface MockBackendOptions {
|
||||
port?: number;
|
||||
|
|
@ -33,9 +34,8 @@ export function createMockBackend(options: MockBackendOptions = {}) {
|
|||
} = options;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({
|
||||
limit: '30mb',
|
||||
}));
|
||||
app.use(createJsonBodyParser());
|
||||
app.use(requestBodyErrorHandler);
|
||||
|
||||
app.post('/v1/chat/completions', (req, res) => {
|
||||
onRequest?.(req);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { initRequestLogsDb } from '../../src/config/request-logs-db';
|
|||
import { getUtcTimestamp } from '../../src/utils/time';
|
||||
import { requireAdminAccess, requireSessionCsrf } from '../../src/utils/adminAuth';
|
||||
import { ModelCatalogService } from '../../src/services/ModelCatalogService';
|
||||
import { createJsonBodyParser, requestBodyErrorHandler } from '../../src/utils/requestBody';
|
||||
|
||||
export function createTestApp() {
|
||||
// Initialize both databases
|
||||
|
|
@ -22,9 +23,8 @@ export function createTestApp() {
|
|||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({
|
||||
limit: '30mb',
|
||||
}));
|
||||
app.use(createJsonBodyParser());
|
||||
app.use(requestBodyErrorHandler);
|
||||
|
||||
app.use('/admin/auth', adminAuthRoutes);
|
||||
app.use('/admin/analytics', requireAdminAccess, requireSessionCsrf, analyticsRoutes);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue