Skip to content

Commit 9d89276

Browse files
committed
Added tests for TCPServer and improved error handling for JsonRpcHandler and FileHandler
1 parent 1be4153 commit 9d89276

6 files changed

Lines changed: 236 additions & 3 deletions

File tree

‎lf_toolkit/io/file_server.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from .base_server import BaseServer
88
from .handler import Handler
9+
from .handler import InvalidSubmissionError
910

1011

1112
class FileHandler(Handler):
@@ -16,8 +17,13 @@ async def dispatch(self, req: str) -> str:
1617
try:
1718
result = await self.handle(command, request)
1819
response = {"command": command, "result": result}
20+
except InvalidSubmissionError as e:
21+
response = {
22+
"command": command,
23+
"error": {"message": str(e), "code": "INVALID_SUBMISSION"},
24+
}
1925
except Exception as e:
20-
response = {"command": command, "error": str(e)}
26+
response = {"command": command, "error": {"message": str(e)}}
2127
return ujson.dumps(response)
2228

2329

‎lf_toolkit/io/handler.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414
from ..shared import Command
1515
from ..shared import Params
1616

17+
# JSON-RPC error code for submissions the user handler cannot process.
18+
INVALID_SUBMISSION_CODE = 422
19+
20+
21+
class InvalidSubmissionError(Exception):
22+
"""Raised when the user handler cannot process the submitted response."""
23+
1724

1825
class Handler(ABC):
1926

@@ -40,6 +47,8 @@ async def _call_user_handler(self, req: str, *args, **kwargs):
4047
return await handler(*args, **kwargs)
4148
else:
4249
return await anyio.to_thread.run_sync(handler, *args, **kwargs)
50+
except ValueError as e:
51+
raise InvalidSubmissionError(str(e)) from e
4352
except Exception as e:
4453
raise ValueError(f"Error calling user handler for '{req}': {e}")
4554

‎lf_toolkit/io/rpc_handler.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
from jsonrpcserver import async_dispatch
66

77
from ..shared import Command
8+
from .handler import INVALID_SUBMISSION_CODE
89
from .handler import Handler
10+
from .handler import InvalidSubmissionError
911

1012

1113
class JsonRpcHandler(Handler):
@@ -31,6 +33,8 @@ async def wrapped(req: dict):
3133
try:
3234
result = await handler.handle(name, {"params": req})
3335
return Success(result)
36+
except InvalidSubmissionError as e:
37+
return Error(INVALID_SUBMISSION_CODE, str(e))
3438
except Exception as e:
3539
# Pass only the message: the exception object is not JSON
3640
# serializable, so sending it as `data` makes serializing the

‎tests/io/file_server_test.py‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ async def test_dispatch_exception_handling(self, handler):
8181

8282
assert response["command"] == "bad_command"
8383
assert "error" in response
84-
assert response["error"] == "Invalid command"
84+
assert response["error"] == {"message": "Invalid command"}
8585
assert "result" not in response
8686

8787
@pytest.mark.asyncio
@@ -195,7 +195,7 @@ async def test_run_with_handler_error(self, temp_files, tmp_path):
195195

196196
assert response["command"] == "failing_command"
197197
assert "error" in response
198-
assert response["error"] == "Processing failed"
198+
assert response["error"] == {"message": "Processing failed"}
199199

200200
@pytest.mark.asyncio
201201
async def test_run_missing_request_file(self, temp_files):
@@ -273,3 +273,37 @@ async def test_run_with_complex_nested_data(self, temp_files, tmp_path):
273273

274274
assert response["command"] == "eval"
275275
assert response["result"] == {"processed": True}
276+
277+
278+
class TestFileHandlerUserErrors:
279+
"""Tests for errors raised by registered user handlers"""
280+
281+
@pytest.mark.asyncio
282+
async def test_value_error_returns_invalid_submission(self):
283+
def preview_fn(response, params):
284+
raise ValueError(f"Failed to parse SymPy expression: {response}")
285+
286+
handler = FileHandler()
287+
handler.register("preview", preview_fn)
288+
289+
request = ujson.dumps({"command": "preview", "params": {"response": "A/(w*"}})
290+
response = ujson.loads(await handler.dispatch(request))
291+
292+
assert response["error"] == {
293+
"message": "Failed to parse SymPy expression: A/(w*",
294+
"code": "INVALID_SUBMISSION",
295+
}
296+
297+
@pytest.mark.asyncio
298+
async def test_unexpected_error_has_no_code(self):
299+
def preview_fn(response, params):
300+
raise RuntimeError("boom")
301+
302+
handler = FileHandler()
303+
handler.register("preview", preview_fn)
304+
305+
request = ujson.dumps({"command": "preview", "params": {"response": "x"}})
306+
response = ujson.loads(await handler.dispatch(request))
307+
308+
assert "code" not in response["error"]
309+
assert "boom" in response["error"]["message"]

‎tests/io/rpc_handler_test.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,43 @@ def chat_health_fn() -> ChatHealthResponse:
6969

7070
assert response["result"]["status"] == "OK"
7171
assert response["result"]["capabilities"]["supportsChat"] is True
72+
73+
74+
class TestJsonRpcHandlerErrors:
75+
76+
@pytest.fixture
77+
def handler(self):
78+
return JsonRpcHandler()
79+
80+
@staticmethod
81+
def _eval_request(response):
82+
return ujson.dumps({
83+
"jsonrpc": "2.0",
84+
"method": "eval",
85+
"params": [{"response": response, "answer": "x", "params": {}}],
86+
"id": 1,
87+
})
88+
89+
@pytest.mark.asyncio
90+
async def test_value_error_returns_invalid_submission_code(self, handler):
91+
def eval_fn(response, answer, params):
92+
raise ValueError(f"Failed to parse SymPy expression: {response}")
93+
94+
handler.register("eval", eval_fn)
95+
96+
response = ujson.loads(await handler.dispatch(self._eval_request("A/(w*")))
97+
98+
assert response["error"]["code"] == 422
99+
assert response["error"]["message"] == "Failed to parse SymPy expression: A/(w*"
100+
101+
@pytest.mark.asyncio
102+
async def test_unexpected_error_returns_generic_code(self, handler):
103+
def eval_fn(response, answer, params):
104+
raise RuntimeError("boom")
105+
106+
handler.register("eval", eval_fn)
107+
108+
response = ujson.loads(await handler.dispatch(self._eval_request("x+1")))
109+
110+
assert response["error"]["code"] == 0
111+
assert "boom" in response["error"]["message"]

‎tests/io/tcp_server_test.py‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import socket
2+
3+
import anyio
4+
import pytest
5+
import ujson
6+
7+
from lf_toolkit.io.stream_io import StreamIO
8+
from lf_toolkit.io.tcp_server import TCPServer
9+
10+
11+
def free_port() -> int:
12+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
13+
s.bind(("127.0.0.1", 0))
14+
return s.getsockname()[1]
15+
16+
17+
@pytest.fixture
18+
def anyio_backend():
19+
return "asyncio"
20+
21+
22+
def make_newline_message(payload: str) -> bytes:
23+
return payload.encode("utf-8") + b"\n"
24+
25+
26+
class FakeStreamIO(StreamIO):
27+
28+
def __init__(self):
29+
self._buffer = b""
30+
self.responses = []
31+
self.close_count = 0
32+
33+
def feed(self, data: bytes):
34+
self._buffer += data
35+
36+
async def read(self, size: int) -> bytes:
37+
if not self._buffer:
38+
raise anyio.EndOfStream()
39+
chunk = self._buffer[:size]
40+
self._buffer = self._buffer[size:]
41+
return chunk
42+
43+
async def write(self, data: bytes):
44+
self.responses.append(data)
45+
46+
async def close(self):
47+
self.close_count += 1
48+
49+
50+
class TestTCPServer:
51+
52+
@pytest.fixture
53+
def stream(self):
54+
return FakeStreamIO()
55+
56+
@pytest.fixture
57+
def server(self):
58+
return TCPServer()
59+
60+
@pytest.mark.anyio
61+
async def test_handles_multiple_messages_newline_framed(self, stream, server):
62+
server.eval(lambda response, answer, params: {"received": True})
63+
64+
stream.feed(
65+
make_newline_message(
66+
ujson.dumps(
67+
{"jsonrpc": "2.0", "method": "eval", "params": [{}], "id": 1}
68+
)
69+
)
70+
)
71+
stream.feed(
72+
make_newline_message(
73+
ujson.dumps(
74+
{"jsonrpc": "2.0", "method": "eval", "params": [{}], "id": 2}
75+
)
76+
)
77+
)
78+
79+
await server._handle_client(stream)
80+
81+
assert len(stream.responses) == 2
82+
for response in stream.responses:
83+
assert response.endswith(b"\n")
84+
assert b"Content-Length:" not in response
85+
86+
@pytest.mark.anyio
87+
async def test_closes_connection_once(self, stream, server):
88+
stream.feed(
89+
make_newline_message(
90+
ujson.dumps(
91+
{"jsonrpc": "2.0", "method": "eval", "params": [{}], "id": 1}
92+
)
93+
)
94+
)
95+
96+
await server._handle_client(stream)
97+
98+
assert stream.close_count == 1
99+
100+
101+
class TestTCPServerLive:
102+
103+
@pytest.mark.anyio
104+
async def test_end_to_end_roundtrip(self):
105+
port = free_port()
106+
server = TCPServer(address=f"127.0.0.1:{port}")
107+
server.eval(lambda response, answer, params: {"is_correct": response == answer})
108+
109+
async with anyio.create_task_group() as tg:
110+
tg.start_soon(server.run)
111+
112+
request = ujson.dumps(
113+
{
114+
"jsonrpc": "2.0",
115+
"method": "eval",
116+
"params": [{"response": "42", "answer": "42"}],
117+
"id": 1,
118+
}
119+
)
120+
121+
client = None
122+
for _ in range(50):
123+
try:
124+
client = await anyio.connect_tcp("127.0.0.1", port)
125+
break
126+
except OSError:
127+
await anyio.sleep(0.05)
128+
assert client is not None, "TCPServer never started listening"
129+
130+
await client.send(request.encode("utf-8") + b"\n")
131+
132+
data = b""
133+
while not data.endswith(b"\n"):
134+
data += await client.receive(4096)
135+
await client.aclose()
136+
137+
response = ujson.loads(data.decode("utf-8"))
138+
assert response["result"]["is_correct"] is True
139+
140+
tg.cancel_scope.cancel()

0 commit comments

Comments
 (0)