From e3d219ddfbb0df03740a2a7edb3f8beaf26d929a Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:58:32 +0900 Subject: [PATCH] fix(server): answer a malformed or non-object request body with 400 Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com> --- python/src/edge0/server/app.py | 13 +++++++-- python/src/edge0/server/chat.py | 8 +++++- python/tests/test_server.py | 51 +++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/python/src/edge0/server/app.py b/python/src/edge0/server/app.py index 9bec35c..f80881a 100644 --- a/python/src/edge0/server/app.py +++ b/python/src/edge0/server/app.py @@ -339,8 +339,17 @@ def do_POST(self): # noqa: N802 if path not in ("/v1/chat/completions", "/v1/completions"): self._json(404, {"error": {"message": f"no route {path}"}}) return - length = int(self.headers.get("Content-Length", 0)) - payload = json.loads(self.rfile.read(length) or b"{}") + try: + length = int(self.headers.get("Content-Length", 0)) + payload = json.loads(self.rfile.read(length) or b"{}") + except ValueError as exc: + # JSONDecodeError and UnicodeDecodeError are ValueErrors; answer + # them like any other malformed request instead of dropping the + # connection with no response. + self._json(400, {"error": { + "message": f"request body is not valid JSON: {exc}", + "type": "invalid_request"}}) + return if path == "/v1/chat/completions": try: parse_chat_request(payload) diff --git a/python/src/edge0/server/chat.py b/python/src/edge0/server/chat.py index 0de3c1e..7f06d75 100644 --- a/python/src/edge0/server/chat.py +++ b/python/src/edge0/server/chat.py @@ -67,8 +67,14 @@ def _template_tool_calls(calls) -> list[dict] | None: def parse_chat_request(payload: dict) -> ChatRequest: + if not isinstance(payload, dict): + raise ValueError("request body must be a JSON object") + messages = payload.get("messages", []) + if not isinstance(messages, list) or not all( + isinstance(m, dict) for m in messages): + raise ValueError("messages must be a list of message objects") msgs = [] - for m in payload.get("messages", []): + for m in messages: role = str(m.get("role", "user")) content = m.get("content") or "" # tool-call-only assistant turns # send content: null, not an omitted key diff --git a/python/tests/test_server.py b/python/tests/test_server.py index a7d5bc9..686ce25 100644 --- a/python/tests/test_server.py +++ b/python/tests/test_server.py @@ -749,6 +749,57 @@ def test_http_rejects_invalid_tool_arguments_before_generation(stream, transport assert engine.generated == [] +def _post_stdlib(server, path, body: bytes): + handler = type("Edge0Handler", (_StdlibHandler,), {"server_q": server}) + httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + req = urlrequest.Request( + f"http://127.0.0.1:{httpd.server_address[1]}{path}", + data=body, headers={"Content-Type": "application/json"}) + with pytest.raises(HTTPError) as exc: + urlrequest.urlopen(req, timeout=10) + with exc.value as response: + return response.code, json.loads(response.read()) + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) + + +@pytest.mark.parametrize("path", ["/v1/chat/completions", "/v1/completions"]) +@pytest.mark.parametrize("body", [b"{not json", b"\xff\xfe"]) +def test_stdlib_http_answers_a_malformed_body_with_400(path, body): + engine = FakeEngine() + code, error = _post_stdlib(QueueServer(engine), path, body) + assert code == 400 + assert error["error"]["type"] == "invalid_request" + assert engine.generated == [] + + +@pytest.mark.parametrize("transport", ["stdlib", "flask"]) +@pytest.mark.parametrize("payload", [ + [1, 2], "hello", {"messages": "hi"}, {"messages": ["hi"]}, + {"messages": [None]}, +]) +def test_http_rejects_a_body_that_is_not_a_chat_request(payload, transport): + engine = FakeEngine() + server = QueueServer(engine) + if transport == "flask": + if not _HAS_FLASK: + pytest.skip("flask is not installed") + response = create_app(server).test_client().post( + "/v1/chat/completions", json=payload) + code, error = response.status_code, response.get_json() + else: + code, error = _post_stdlib( + server, "/v1/chat/completions", json.dumps(payload).encode()) + assert code == 400 + assert error["error"]["type"] == "invalid_request" + assert engine.generated == [] + + def test_stdlib_http_end_to_end(): eng = FakeEngine() srv = QueueServer(eng, model_name="fake")