diff --git a/server/providers/common/request.js b/server/providers/common/request.js index 11e0f128f..93bbbaa85 100644 --- a/server/providers/common/request.js +++ b/server/providers/common/request.js @@ -17,21 +17,29 @@ async function readRequestBodyCapped(req, maxBytes) { function readRequestBody(req, maxBytes = 1024 * 1024) { return new Promise((resolve, reject) => { let total = 0; + let bodyTooLarge = false; const chunks = []; req.on('data', (chunk) => { + // Keep draining after the cap without retaining more bytes. Destroying + // the request also destroys its socket, so the handler cannot deliver + // the fixed error response it constructs for BODY_TOO_LARGE. + if (bodyTooLarge) return; total += chunk.length; if (total > maxBytes) { // Same marker readRequestBodyCapped sets, so a caller can tell an // oversized body from a genuine failure without matching on text. + bodyTooLarge = true; + chunks.length = 0; const error = new Error(`Request body exceeds ${maxBytes} bytes`); error.code = 'BODY_TOO_LARGE'; reject(error); - req.destroy(); return; } chunks.push(chunk); }); - req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('end', () => { + if (!bodyTooLarge) resolve(Buffer.concat(chunks).toString('utf8')); + }); req.on('error', reject); }); } diff --git a/server/providers/openai/debug-log.js b/server/providers/openai/debug-log.js index c5c60f6e0..6d781a31c 100644 --- a/server/providers/openai/debug-log.js +++ b/server/providers/openai/debug-log.js @@ -46,6 +46,10 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) { () => 0, ); if (size + Buffer.byteLength(line) > REALTIME_DEBUG_LOG_MAX_FILE_BYTES) { + // Node does not replace an existing destination on Windows. Remove the + // retained generation explicitly so every rotation behaves the same on + // supported platforms rather than failing after the first 64 MB. + await fsp.rm(`${logFile}.1`, { force: true }); await fsp.rename(logFile, `${logFile}.1`); } await fsp.appendFile(logFile, line); @@ -67,7 +71,7 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) { if (!allow(clientKey(req))) { res.writeHead(429, { 'Content-Type': 'application/json', - 'Retry-After': '5', + 'Retry-After': '60', }); res.end(JSON.stringify({ error: 'Rate limit exceeded' })); return; diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index 7073957ae..26eebfa08 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { createServer } from 'node:http'; import { Readable } from 'node:stream'; import { mkdtempSync, @@ -365,7 +366,7 @@ test('the debug-log sink stays bounded, rate limited, and quiet about failures', else if (response.status === 204) accepted += 1; } assert.ok(limited, 'the sink refuses a caller past its per-minute ceiling'); - assert.equal(limited.headers['retry-after'], '5'); + assert.equal(limited.headers['retry-after'], '60'); assert.deepEqual(limited.json(), { error: 'Rate limit exceeded' }); // The limiter counts requests rather than successful writes, so the malformed // record above already spent one of the 120 slots. @@ -379,6 +380,24 @@ test('the debug-log sink stays bounded, rate limited, and quiet about failures', assert.ok(lines.every((line) => JSON.parse(line).loggedAt)); }); +test('an oversized debug-log request receives the fixed error response', async (t) => { + const handler = install(openAiRealtimeProxy({ sourceRoot: root(t) })).get( + '/api/realtime/debug-log', + ); + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + + const response = await fetch( + `http://127.0.0.1:${server.address().port}/api/realtime/debug-log`, + { method: 'POST', body: 'x'.repeat(8 * 1024 * 1024 + 1) }, + ); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { + error: 'Failed to write Realtime debug log', + }); +}); + test('the debug log rotates instead of growing without bound', async (t) => { const sourceRoot = root(t); const handler = install(openAiRealtimeProxy({ sourceRoot })).get( @@ -390,7 +409,9 @@ test('the debug log rotates instead of growing without bound', async (t) => { // single page could grow it for as long as the dev server ran. Each record // here is ~1 MB, well inside the body cap. const pad = 'x'.repeat(1024 * 1024); - for (let n = 0; n < 40; n += 1) { + // Seventy-two records force two rotations. The second one replaces an + // existing `.1`, which requires an explicit removal on Windows. + for (let n = 0; n < 72; n += 1) { assert.equal( ( await request(handler, { @@ -409,7 +430,7 @@ test('the debug log rotates instead of growing without bound', async (t) => { live + previous <= 64 * 1024 * 1024, 'both generations together stay within twice the ceiling', ); - // Unrotated, these records would be ~42 MB in one file. - assert.ok(live + previous < 42 * 1024 * 1024); + // Unrotated, these records would be ~75 MB in one file. + assert.ok(live + previous < 64 * 1024 * 1024); assert.ok(!existsSync(`${file}.2`), 'exactly one generation is retained'); });