From d6eded8ab85b4f7246b8f66d398461baa0b1213a Mon Sep 17 00:00:00 2001 From: autoantohaki Date: Wed, 21 Oct 2026 17:08:03 -0300 Subject: [PATCH] fix: return client errors for malformed, blank, and oversized requests --- docs/http-api.md | 2 +- src/http.ts | 32 +++++++++++++++++++++++++++----- tests/http.test.ts | 18 ++++++++++++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/docs/http-api.md b/docs/http-api.md index 2fe2c05..22d1066 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -18,6 +18,6 @@ The response contains the final text and the thread identifier: } ``` -Request bodies are limited to one megabyte and prompt text is limited to 100,000 characters. Deployments exposed beyond loopback should add authentication, authorization, rate limits, and TLS at the edge. +Request bodies are limited to one megabyte and prompt text is limited to 100,000 characters. Malformed JSON and blank or invalid input return `400`, and oversized bodies return `413`. Deployments exposed beyond loopback should add authentication, authorization, rate limits, and TLS at the edge. `POST /v1/chat/stream` accepts the same body and returns server-sent events. `text` events carry model chunks, followed by one `done` event containing the thread identifier. Errors that occur after streaming begins are delivered as `error` events. diff --git a/src/http.ts b/src/http.ts index 52ef741..eec001f 100644 --- a/src/http.ts +++ b/src/http.ts @@ -7,22 +7,44 @@ import { runAgent, streamAgent } from "./runner.js"; import { toError } from "./errors.js"; const requestSchema = z.object({ - input: z.string().min(1).max(100_000), + input: z + .string() + .max(100_000) + .refine((value) => value.trim().length > 0, "Input must not be empty"), threadId: z.string().min(1).max(200).optional(), }); export type HttpRuntime = { server: Server; close: () => Promise }; +class RequestError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = "RequestError"; + } +} + async function readJson(request: IncomingMessage, limit = 1_000_000): Promise { const chunks: Buffer[] = []; let size = 0; for await (const chunk of request) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); size += buffer.length; - if (size > limit) throw new Error("Request body is too large"); + if (size > limit) throw new RequestError(413, "Request body is too large"); chunks.push(buffer); } - return JSON.parse(Buffer.concat(chunks).toString("utf8")); + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + throw new RequestError(400, "Request body must be valid JSON"); + } +} + +function errorStatus(error: Error): number { + if (error instanceof RequestError) return error.status; + return error.name === "ZodError" ? 400 : 500; } function sendJson(response: ServerResponse, status: number, body: unknown): void { @@ -78,7 +100,7 @@ export async function startHttp( const error = toError(cause); logger.warn({ error }, "streaming agent request failed"); if (!response.headersSent) { - sendJson(response, error.name === "ZodError" ? 400 : 500, { error: error.message }); + sendJson(response, errorStatus(error), { error: error.message }); } else { response.write(`event: error\ndata: ${JSON.stringify({ error: error.message })}\n\n`); response.end(); @@ -102,7 +124,7 @@ export async function startHttp( } catch (cause) { const error = toError(cause); logger.warn({ error }, "agent request failed"); - sendJson(response, error.name === "ZodError" ? 400 : 500, { error: error.message }); + sendJson(response, errorStatus(error), { error: error.message }); } } diff --git a/tests/http.test.ts b/tests/http.test.ts index 30de8c7..81356ee 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -103,4 +103,22 @@ describe("agent HTTP API", () => { expect(signal?.aborted).toBe(true); }); }); + + it("answers malformed, blank, and oversized requests with client errors", async () => { + const config = loadConfig({ AGENT_PORT: "41422", AGENT_LOG_LEVEL: "silent" }); + const invoke = vi.fn(); + runtime = await startHttp( + config, + { invoke } as unknown as ImpossibleAgent, + pino({ level: "silent" }), + ); + const post = (path: string, body: string) => + fetch(`http://127.0.0.1:41422${path}`, { method: "POST", body }); + for (const path of ["/v1/chat", "/v1/chat/stream"]) { + expect((await post(path, "{not json")).status).toBe(400); + expect((await post(path, JSON.stringify({ input: " " }))).status).toBe(400); + expect((await post(path, JSON.stringify({ input: "x".repeat(1_000_001) }))).status).toBe(413); + } + expect(invoke).not.toHaveBeenCalled(); + }); });