Skip to content

Commit fc545d6

Browse files
authored
fix: align JSON-RPC error handling across transports (#174)
1 parent 2ad204b commit fc545d6

12 files changed

Lines changed: 286 additions & 65 deletions

File tree

‎src/acp/_jsonrpc.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from typing import Any
5+
6+
from .exceptions import RequestError
7+
8+
9+
def decode_message(payload: str | bytes) -> dict[str, Any] | None:
10+
"""Decode a message, returning None for unsupported non-empty batches."""
11+
try:
12+
message = json.loads(payload)
13+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
14+
raise RequestError.parse_error() from exc
15+
return require_message_object(message)
16+
17+
18+
def require_message_object(message: Any) -> dict[str, Any] | None:
19+
"""Reject invalid root values while leaving non-empty batches unsupported."""
20+
if isinstance(message, dict) and message:
21+
return message
22+
if isinstance(message, list) and message:
23+
return None
24+
raise RequestError.invalid_request()
25+
26+
27+
def error_response(error: RequestError) -> dict[str, Any]:
28+
return {"jsonrpc": "2.0", "id": None, "error": error.to_error_obj()}

‎src/acp/_sse.py‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111
import json
1212
from typing import TYPE_CHECKING, Any
1313

14+
from ._jsonrpc import decode_message
15+
from .exceptions import RequestError
16+
1417
if TYPE_CHECKING:
15-
from collections.abc import AsyncIterator
18+
from collections.abc import AsyncIterator, Awaitable, Callable
1619

1720
__all__ = [
1821
"parse_sse_stream",
@@ -36,16 +39,19 @@ def serialize_sse_keepalive() -> bytes:
3639
return b": keepalive\n\n"
3740

3841

39-
def _decode_event(data_lines: list[str]) -> dict[str, Any] | None:
42+
async def _decode_event(
43+
data_lines: list[str], on_error: Callable[[RequestError], Awaitable[None]] | None
44+
) -> dict[str, Any] | None:
4045
"""Decode buffered ``data:`` lines into a JSON object, or None to skip."""
4146
payload = "\n".join(data_lines)
4247
if not payload:
4348
return None
4449
try:
45-
message = json.loads(payload)
46-
except json.JSONDecodeError:
50+
return decode_message(payload)
51+
except RequestError as error:
52+
if on_error is not None:
53+
await on_error(error)
4754
return None
48-
return message if isinstance(message, dict) else None
4955

5056

5157
def _append_field(line: str, data_lines: list[str]) -> None:
@@ -57,7 +63,9 @@ def _append_field(line: str, data_lines: list[str]) -> None:
5763
data_lines.append(value)
5864

5965

60-
async def parse_sse_stream(chunks: AsyncIterator[bytes]) -> AsyncIterator[dict[str, Any]]:
66+
async def parse_sse_stream(
67+
chunks: AsyncIterator[bytes], *, on_error: Callable[[RequestError], Awaitable[None]] | None = None
68+
) -> AsyncIterator[dict[str, Any]]:
6169
"""Parse an SSE byte stream, yielding decoded JSON-RPC ``data:`` payloads.
6270
6371
Comments (lines starting with ``:``) and non-``data`` fields are ignored.
@@ -75,7 +83,7 @@ async def parse_sse_stream(chunks: AsyncIterator[bytes]) -> AsyncIterator[dict[s
7583
line, buffer = buffer.split("\n", 1)
7684
line = line.rstrip("\r")
7785
if line == "":
78-
event = _decode_event(data_lines)
86+
event = await _decode_event(data_lines, on_error)
7987
data_lines = []
8088
if event is not None:
8189
yield event
@@ -85,6 +93,6 @@ async def parse_sse_stream(chunks: AsyncIterator[bytes]) -> AsyncIterator[dict[s
8593
decoder.decode(b"", final=True)
8694

8795
# Flush a trailing event with no terminating blank line.
88-
event = _decode_event(data_lines)
96+
event = await _decode_event(data_lines, on_error)
8997
if event is not None:
9098
yield event

‎src/acp/_transport.py‎

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,19 @@
11
"""Message-level transport seam for :class:`acp.connection.Connection`.
22
3-
Historically the connection spoke directly to ``asyncio`` byte streams with
4-
newline framing. To support message-oriented remote transports (Streamable
5-
HTTP + WebSocket) we introduce a small :class:`Transport` protocol that moves
6-
JSON-RPC *messages* (already-decoded ``dict`` payloads) instead of bytes.
7-
8-
The existing stdio path is re-expressed on top of this seam via
9-
:class:`NdjsonTransport`, which wraps the current byte-stream framing so there
10-
is **zero behaviour change** for stdio users. :func:`memory_transport_pair`
11-
gives two linked in-memory transports for in-process connections and tests.
3+
Transports exchange decoded JSON-RPC objects. :class:`NdjsonTransport` provides
4+
newline framing for byte streams, while :func:`memory_transport_pair` connects
5+
two peers in memory.
126
"""
137

148
from __future__ import annotations
159

1610
import asyncio
1711
import contextlib
18-
import json
19-
import logging
2012
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
2113

14+
from ._jsonrpc import decode_message, error_response
15+
from .exceptions import RequestError
16+
2217
if TYPE_CHECKING:
2318
from collections.abc import Awaitable
2419

@@ -49,12 +44,7 @@ async def close(self) -> None: ...
4944

5045

5146
class NdjsonTransport:
52-
"""Transport backed by newline-delimited JSON over asyncio byte streams.
53-
54-
This preserves the exact framing, buffer-limit-overrun handling, and receive
55-
timeout semantics that previously lived inside ``Connection`` so that stdio
56-
behaviour is byte-for-byte unchanged.
57-
"""
47+
"""Transport backed by newline-delimited JSON over asyncio byte streams."""
5848

5949
def __init__(
6050
self,
@@ -66,6 +56,7 @@ def __init__(
6656
self._reader = reader
6757
self._sender = sender
6858
self._receive_timeout = receive_timeout
59+
self._error_send_failed = False
6960

7061
async def send(self, message: dict[str, Any]) -> None:
7162
await self._sender.send(message)
@@ -79,12 +70,15 @@ async def receive(self) -> dict[str, Any] | None:
7970
if not line:
8071
continue
8172
try:
82-
message = json.loads(line)
83-
except Exception:
84-
logging.exception("Error parsing JSON-RPC message")
73+
message = decode_message(line)
74+
except RequestError as error:
75+
if not self._error_send_failed:
76+
try:
77+
await self.send(error_response(error))
78+
except ConnectionError:
79+
self._error_send_failed = True
8580
continue
86-
if not isinstance(message, dict):
87-
logging.warning("Ignoring non-object JSON-RPC message")
81+
if message is None:
8882
continue
8983
return message
9084

‎src/acp/http/asgi.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from starlette.routing import Route, WebSocketRoute
1919
from starlette.websockets import WebSocket
2020

21+
from .._jsonrpc import error_response
22+
from ..exceptions import RequestError
2123
from ..ws.server import handle_websocket
2224
from .protocol import ACP_ENDPOINT_PATH, CONNECTION_ID_HEADER, CONTENT_TYPE_SSE, SESSION_ID_HEADER
2325
from .server import AcpServer, AgentFactory
@@ -62,7 +64,7 @@ async def _post(server: AcpServer, request: Request) -> Response:
6264
try:
6365
message = await request.json()
6466
except (json.JSONDecodeError, UnicodeDecodeError):
65-
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
67+
return JSONResponse(error_response(RequestError.parse_error()), status_code=400)
6668
return await server.handle_post(
6769
message,
6870
content_type=request.headers.get("content-type"),

‎src/acp/http/client.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
import contextlib
2424
from typing import TYPE_CHECKING, Any
2525

26+
from .._jsonrpc import error_response
2627
from .._sse import parse_sse_stream
28+
from ..exceptions import RequestError
2729
from .protocol import (
2830
CONNECTION_ID_HEADER,
2931
CONTENT_TYPE_JSON,
@@ -190,7 +192,7 @@ async def _consume_stream(self, *, session_id: str | None) -> None:
190192
async with self._client.stream("GET", self._url, headers=headers) as response:
191193
if response.status_code != 200:
192194
return
193-
async for event in parse_sse_stream(_aiter_raw(response)):
195+
async for event in parse_sse_stream(_aiter_raw(response), on_error=self._send_protocol_error):
194196
self._handle_incoming(event)
195197
except (httpx2.HTTPError, asyncio.CancelledError):
196198
return
@@ -215,6 +217,10 @@ def _on_stream_closed(self, session_id: str | None) -> None:
215217
self._pending_loads.clear()
216218
self._inbox.put_nowait(_EOF)
217219

220+
async def _send_protocol_error(self, error: RequestError) -> None:
221+
with contextlib.suppress(ConnectionError, httpx2.HTTPError, AcpHttpStatusError):
222+
await self.send(error_response(error))
223+
218224
def _handle_incoming(self, message: dict[str, Any]) -> None:
219225
# Load and resume responses may omit sessionId; the ID is in the request.
220226
if is_response_message(message):

‎src/acp/http/server.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@
2929
except ImportError as exc:
3030
raise ImportError("The HTTP server requires the 'http' extra: pip install agent-client-protocol[http]") from exc
3131

32+
from .._jsonrpc import error_response, require_message_object
3233
from .._sse import serialize_sse_event, serialize_sse_keepalive
3334
from ..agent.connection import AgentSideConnection
35+
from ..exceptions import RequestError
3436
from .protocol import (
3537
CONNECTION_ID_HEADER,
3638
SESSION_ATTACH_METHODS,
@@ -249,10 +251,12 @@ async def handle_post(
249251
) -> Response:
250252
if content_type is None or not content_type.lower().startswith("application/json"):
251253
return JSONResponse({"error": "Content-Type must be application/json"}, status_code=415)
252-
if isinstance(message, list):
254+
try:
255+
message = require_message_object(message)
256+
except RequestError as error:
257+
return JSONResponse(error_response(error), status_code=400)
258+
if message is None:
253259
return JSONResponse({"error": "Batch requests are not supported"}, status_code=501)
254-
if not isinstance(message, dict):
255-
return JSONResponse({"error": "Invalid JSON-RPC message"}, status_code=400)
256260

257261
if is_initialize_request(message):
258262
return await self._handle_initialize(message)

‎src/acp/ws/client.py‎

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from typing import TYPE_CHECKING, Any
1414

1515
from .._cookies import MemoryAcpCookieStore
16+
from .._jsonrpc import decode_message, error_response
1617
from ..exceptions import RequestError
1718

1819
try:
@@ -54,21 +55,14 @@ async def receive(self) -> dict[str, Any] | None:
5455
if isinstance(frame, bytes):
5556
continue
5657
try:
57-
message = json.loads(frame)
58-
except json.JSONDecodeError:
59-
error = RequestError.parse_error()
60-
else:
61-
if isinstance(message, dict) and message:
58+
message = decode_message(frame)
59+
if message is not None:
6260
return message
63-
if isinstance(message, list) and message:
64-
# Batch support is outside this message-level transport.
65-
continue
66-
error = RequestError.invalid_request()
67-
# Sending while closing waits for EOF, which may require draining
68-
# buffered frames first. Keep receiving instead of waiting here.
69-
if self._ws.state == websockets.State.OPEN:
70-
with contextlib.suppress(websockets.ConnectionClosed):
71-
await self.send({"jsonrpc": "2.0", "id": None, "error": error.to_error_obj()})
61+
except RequestError as error:
62+
# Sending while closing may wait for buffered frames to drain.
63+
if self._ws.state == websockets.State.OPEN:
64+
with contextlib.suppress(websockets.ConnectionClosed):
65+
await self.send(error_response(error))
7266

7367
async def close(self) -> None:
7468
if self._closed:

‎src/acp/ws/server.py‎

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
from __future__ import annotations
44

55
import contextlib
6-
import json
76
import logging
87
import uuid
98
from typing import TYPE_CHECKING, Any
109

1110
from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState
1211

12+
from .._jsonrpc import decode_message, error_response
1313
from ..agent.connection import AgentSideConnection
1414
from ..exceptions import RequestError
1515
from ..http.protocol import CONNECTION_ID_HEADER
@@ -44,20 +44,14 @@ async def receive(self) -> dict[str, Any] | None:
4444
logging.warning("Ignoring binary WebSocket frame; ACP messages must be sent as text frames")
4545
continue
4646
try:
47-
message = json.loads(event["text"])
48-
except json.JSONDecodeError:
49-
error = RequestError.parse_error()
50-
else:
51-
if isinstance(message, dict) and message:
47+
message = decode_message(event["text"])
48+
if message is not None:
5249
return message
53-
if isinstance(message, list) and message:
54-
# Batch support is outside this message-level transport.
55-
continue
56-
error = RequestError.invalid_request()
57-
# A failed write must not discard valid messages buffered by ASGI.
58-
if self._ws.application_state == WebSocketState.CONNECTED:
59-
with contextlib.suppress(WebSocketDisconnect):
60-
await self.send({"jsonrpc": "2.0", "id": None, "error": error.to_error_obj()})
50+
except RequestError as error:
51+
# A failed write must not discard valid messages buffered by ASGI.
52+
if self._ws.application_state == WebSocketState.CONNECTED:
53+
with contextlib.suppress(WebSocketDisconnect):
54+
await self.send(error_response(error))
6155
return None
6256

6357
async def close(self) -> None:

‎tests/http/test_asgi.py‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
("POST", {"Content-Type": "text/plain"}, "{}", 415),
2929
("POST", {"Content-Type": "application/json"}, "invalid", 400),
3030
("POST", {"Content-Type": "application/json"}, "null", 400),
31-
("POST", {"Content-Type": "application/json"}, "[]", 501),
31+
("POST", {"Content-Type": "application/json"}, "[]", 400),
32+
("POST", {"Content-Type": "application/json"}, '[{"jsonrpc":"2.0","method":"probe","id":1}]', 501),
3233
("GET", {"Accept": "application/json"}, "", 406),
3334
("GET", {"Accept": "text/event-stream"}, "", 400),
3435
("GET", {"Accept": "text/event-stream", CONNECTION_ID_HEADER: "unknown"}, "", 404),
@@ -47,6 +48,35 @@ async def test_http_errors(method: str, headers: dict[str, str], body: str, stat
4748
assert "error" in response.json()
4849

4950

51+
@pytest.mark.asyncio
52+
@pytest.mark.parametrize(
53+
("payload", "code"),
54+
[
55+
("invalid json", -32700),
56+
("null", -32600),
57+
("[]", -32600),
58+
("{}", -32600),
59+
("true", -32600),
60+
("42", -32600),
61+
('"text"', -32600),
62+
],
63+
)
64+
async def test_invalid_post_returns_protocol_error_without_losing_connection(payload: str, code: int) -> None:
65+
app = create_asgi_app(lambda conn: TestAgent())
66+
async with (
67+
app.router.lifespan_context(app),
68+
httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://test") as client,
69+
):
70+
initialized = await client.post("/acp", json=INITIALIZE)
71+
headers = {"Content-Type": "application/json", CONNECTION_ID_HEADER: initialized.headers[CONNECTION_ID_HEADER]}
72+
response = await client.post("/acp", content=payload, headers=headers)
73+
assert response.status_code == 400
74+
body = response.json()
75+
assert body["jsonrpc"] == "2.0" and body["id"] is None
76+
assert body["error"]["code"] == code
77+
assert (await client.delete("/acp", headers=headers)).status_code == 202
78+
79+
5080
@pytest.mark.asyncio
5181
@pytest.mark.parametrize("method", ["PUT", "OPTIONS"])
5282
async def test_unsupported_methods_do_not_open_a_stream(method: str) -> None:

0 commit comments

Comments
 (0)