From 00c63b6fc60e0885c420b7cfb8422b3a9023e7b2 Mon Sep 17 00:00:00 2001 From: Houyun Sun <18763177732@139.com> Date: Sun, 1 Nov 2026 22:12:36 +0800 Subject: [PATCH] fix(a2a): answer an unparseable body with a JSON-RPC parse error `body = await request.json()` sat inside the handler's own try, so a body the proxy could not read surfaced as a -32603 Internal error carrying the decoder text and a 500, plus an "Error invoking agent" traceback in the proxy log. A body that parsed to something other than an object ([] or "x") was worse: the handler left through an AttributeError, since every check below it assumes a dict, and the client got a bare-text 500 it cannot parse at all. A2A clients map the JSON-RPC code to a typed error, so what they saw was a generic internal failure instead of the parse error the spec reserves for exactly this. Read the body ahead of the try that owns the internal-error branch and answer -32700 for something unreadable, -32600 for JSON that is not an object, both with the null id JSON-RPC 2.0 asks for on a request whose id is unknown. Fixes #49569 --- .../proxy/agent_endpoints/a2a_endpoints.py | 12 ++++- .../agent_endpoints/test_a2a_endpoints.py | 51 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/agent_endpoints/a2a_endpoints.py b/litellm/proxy/agent_endpoints/a2a_endpoints.py index 3dfaa7f213e..1995b2a8312 100644 --- a/litellm/proxy/agent_endpoints/a2a_endpoints.py +++ b/litellm/proxy/agent_endpoints/a2a_endpoints.py @@ -12,6 +12,7 @@ The A2A SDK can point to LiteLLM's URL and invoke agents registered with LiteLLM import json from collections.abc import AsyncGenerator, Callable, Coroutine, Mapping +from json import JSONDecodeError from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, Protocol from urllib.parse import urlparse @@ -819,7 +820,16 @@ async def invoke_agent_a2a( body: dict[str, Any] = {} request_data: dict[str, object] = body try: - body = await request.json() + # JSON-RPC has its own error codes for a bad body, and callers (the a2a-sdk + # client included) map them to typed errors. Letting json() raise would send + # them an -32603 internal error, or nothing at all. + try: + body = await request.json() + except (JSONDecodeError, UnicodeDecodeError): + return _jsonrpc_error(None, -32700, "Parse error") + if not isinstance(body, dict): + return _jsonrpc_error(None, -32600, "Invalid Request: body must be a JSON object") + request_data = body verbose_proxy_logger.debug("A2A request for agent '%s': %s", agent_id, body) diff --git a/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py b/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py index 88da07c612b..4d35cd387e7 100644 --- a/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py +++ b/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py @@ -3196,3 +3196,54 @@ def test_a2a_http_refusals_answer_as_jsonrpc_errors_with_a_null_id_for_unparseab assert body["error"]["code"] == -32000 assert body["error"]["message"] == "Agent 'agent-1' is not allowed" assert body["error"]["data"]["code"] == "403" + + +def _mock_request(raw_body: bytes) -> MagicMock: + """A request whose json() behaves like Starlette's: empty body is JSONDecodeError.""" + req: Final = MagicMock() + req.headers = {} + req.body = AsyncMock(return_value=raw_body) + + async def _json() -> object: + return json.loads(raw_body) + + req.json = AsyncMock(side_effect=_json) + return req + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("raw_body", "expected_code", "expected_message"), + [ + (b"", -32700, "Parse error"), + (b"{", -32700, "Parse error"), + (b"[]", -32600, "Invalid Request: body must be a JSON object"), + (b'"x"', -32600, "Invalid Request: body must be a JSON object"), + ], +) +async def test_unparseable_bodies_answer_as_jsonrpc_errors( + raw_body: bytes, expected_code: int, expected_message: str +) -> None: + """A2A clients map the JSON-RPC code to a typed error, so an unreadable body has to come back + as -32700/-32600 with a null id -- not as -32603 internal error (and not as an AttributeError).""" + from litellm.proxy.agent_endpoints.a2a_endpoints import invoke_agent_a2a + + with ( + patch("litellm.proxy.proxy_server.general_settings", {}), + patch("litellm.proxy.proxy_server.proxy_config", MagicMock()), + patch("litellm.proxy.proxy_server.version", "1.0.0"), + patch("litellm.a2a_protocol.main.A2A_SDK_AVAILABLE", True), + ): + response: Final = await invoke_agent_a2a( + agent_id="agent-1", + request=_mock_request(raw_body), + fastapi_response=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "jsonrpc": "2.0", + "id": None, + "error": {"code": expected_code, "message": expected_message}, + }