feat(request-body): implement custom JSON body parser and error handler

This commit is contained in:
Kyush 2026-04-23 01:32:58 +09:00
commit f8c603fafb
5 changed files with 128 additions and 9 deletions

View file

@ -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);

View 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);
};

View 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();
});
});

View file

@ -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);

View file

@ -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);