Skip to content

Commit df5efa3

Browse files
m-messerclaude
andauthored
Report unprocessable submissions as invalid-submission errors (422) (#14)
* Added tests for TCPServer and improved error handling for JsonRpcHandler and FileHandler * Remove TCPServer tests that depend on the unmerged feature/tcp branch tests/io/tcp_server_test.py imports lf_toolkit.io.tcp_server, which only exists on feature/tcp, so test collection failed on this branch. The tests belong with that branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1be4153 commit df5efa3

5 files changed

Lines changed: 96 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"]

0 commit comments

Comments
 (0)