Skip to content

Commit 8e32d53

Browse files
fix(compat): return A2A error codes from the v0.3 JSON-RPC adapter (#1369)
# Description A2A errors raised while handling v0.3 JSON-RPC requests fell through to the generic `except Exception` in `JSONRPC03Adapter` and were returned as `-32603 InternalError`. v0.3 clients could not distinguish e.g. `TaskNotFoundError` (`-32001`) from a server failure. This maps `A2AError`s to their JSON-RPC codes via `JSON_RPC_ERROR_CODE_MAP` in both paths, matching what `JsonRpcDispatcher` does for v1.0: - non-streaming: `handle_request` catches `A2AError` before the generic handler, and `_generate_error_response` builds a v0.3 `JSONRPCError` from it - streaming: `event_generator` yields the mapped error event instead of `InternalError` Errors that only exist in v1.0 (`-32008` ExtensionSupportRequired, `-32009` VersionNotSupported) are not part of the v0.3 spec, so they still map to `-32603`. This keeps the existing expectation in `tests/integration/test_version_header.py`. **Tests** (`tests/compat/v0_3/test_jsonrpc_app_compat.py`): `tasks/get` not found, `tasks/cancel` with `TaskNotFoundError` / `TaskNotCancelableError` / `UnsupportedOperationError`, and an error raised mid-stream on `message/stream`. All fail on `main` and pass with this change. Full suite passes locally; `ruff check`, `ruff format` and `ty check` are clean. - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - [x] Ensure the tests and linter pass - [x] Appropriate docs were updated (if necessary) Fixes #1368 🦕 --------- Co-authored-by: mykytanetipa <mykytanetipa@google.com>
1 parent b22a68d commit 8e32d53

2 files changed

Lines changed: 138 additions & 1 deletion

File tree

‎src/a2a/compat/v0_3/jsonrpc_adapter.py‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22

33
from collections.abc import AsyncIterable, AsyncIterator
4-
from typing import TYPE_CHECKING, Any
4+
from typing import TYPE_CHECKING, Any, get_args
55

66
from sse_starlette.sse import EventSourceResponse
77
from starlette.responses import JSONResponse
@@ -42,6 +42,7 @@
4242
create_event_source_response,
4343
)
4444
from a2a.utils import constants
45+
from a2a.utils.errors import JSON_RPC_ERROR_CODE_MAP, A2AError
4546
from a2a.utils.version_validator import validate_version
4647

4748

@@ -93,6 +94,12 @@ def _generate_error_response(
9394
{'jsonrpc': '2.0', 'id': request_id, 'error': err_dict}
9495
)
9596

97+
if isinstance(error, A2AError):
98+
err_dict = _to_v03_jsonrpc_error(error).model_dump(by_alias=True)
99+
return JSONResponse(
100+
{'jsonrpc': '2.0', 'id': request_id, 'error': err_dict}
101+
)
102+
96103
internal_error = CoreInternalError(message=str(error))
97104
return JSONResponse(
98105
{
@@ -141,6 +148,8 @@ async def handle_request(
141148
return await self._process_non_streaming_request(
142149
request_id, specific_request, call_context
143150
)
151+
except A2AError as e:
152+
return self._generate_error_response(request_id, e)
144153
except Exception as e:
145154
logger.exception('Unhandled exception in v0.3 JSONRPCAdapter')
146155
return self._generate_error_response(
@@ -264,6 +273,17 @@ async def event_generator(
264273
by_alias=True, exclude_none=True
265274
)
266275
}
276+
except A2AError as e:
277+
err_resp = types_v03.SendStreamingMessageResponse(
278+
root=types_v03.JSONRPCErrorResponse(
279+
id=request_id, error=_to_v03_jsonrpc_error(e)
280+
)
281+
)
282+
yield {
283+
'data': err_resp.model_dump_json(
284+
by_alias=True, exclude_none=True
285+
)
286+
}
267287
except Exception as e:
268288
logger.exception(
269289
'Error during stream generation in v0.3 JSONRPCAdapter'
@@ -284,3 +304,25 @@ async def event_generator(
284304
event_generator(stream_gen),
285305
shutdown_grace_period=self._shutdown_grace_period,
286306
)
307+
308+
309+
# Error codes defined by the A2A v0.3 specification, derived from the v0.3
310+
# error models. Codes added in v1.0 (e.g. -32009 VersionNotSupported) are
311+
# unknown to v0.3 clients.
312+
_V03_ERROR_CODES = frozenset(
313+
cls.model_fields['code'].default
314+
for cls in get_args(types_v03.A2AError.model_fields['root'].annotation)
315+
)
316+
317+
318+
def _to_v03_jsonrpc_error(error: A2AError) -> types_v03.JSONRPCError:
319+
"""Maps an A2AError to a v0.3 JSON-RPC error carrying its A2A error code.
320+
321+
Errors without a v0.3 equivalent are reported as InternalError (-32603).
322+
"""
323+
code = JSON_RPC_ERROR_CODE_MAP.get(type(error), -32603)
324+
if code not in _V03_ERROR_CODES:
325+
code = -32603
326+
return types_v03.JSONRPCError(
327+
code=code, message=error.message, data=error.data
328+
)

‎tests/compat/v0_3/test_jsonrpc_app_compat.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23

34
from collections.abc import AsyncIterator
@@ -20,6 +21,12 @@
2021
TaskState as TaskState10,
2122
TaskStatus as TaskStatus10,
2223
)
24+
from a2a.utils.errors import (
25+
A2AError,
26+
TaskNotCancelableError,
27+
TaskNotFoundError,
28+
UnsupportedOperationError,
29+
)
2330
from starlette.applications import Starlette
2431
from starlette.testclient import TestClient
2532

@@ -176,3 +183,91 @@ def test_get_extended_agent_card_v03_compat(
176183
assert 'result' in data
177184
# The result should be a v0.3 AgentCard
178185
assert 'supportsAuthenticatedExtendedCard' in data['result']
186+
187+
188+
def test_get_task_not_found_v03_compat(
189+
client: TestClient, mock_handler: AsyncMock
190+
) -> None:
191+
mock_handler.on_get_task.return_value = None
192+
request_payload = {
193+
'jsonrpc': '2.0',
194+
'id': '4',
195+
'method': 'tasks/get',
196+
'params': {'id': 'missing_task_id'},
197+
}
198+
199+
response = client.post('/', json=request_payload)
200+
assert response.status_code == 200
201+
data = response.json()
202+
203+
assert data['id'] == '4'
204+
assert data['error']['code'] == -32001
205+
assert data['error']['message'] == 'Task not found'
206+
207+
208+
@pytest.mark.parametrize(
209+
('error', 'expected_code'),
210+
[
211+
(TaskNotFoundError(), -32001),
212+
(TaskNotCancelableError(), -32002),
213+
(UnsupportedOperationError(), -32004),
214+
],
215+
)
216+
def test_cancel_task_v03_compat_maps_a2a_error_codes(
217+
client: TestClient,
218+
mock_handler: AsyncMock,
219+
error: A2AError,
220+
expected_code: int,
221+
) -> None:
222+
mock_handler.on_cancel_task.side_effect = error
223+
request_payload = {
224+
'jsonrpc': '2.0',
225+
'id': '5',
226+
'method': 'tasks/cancel',
227+
'params': {'id': 'test_task_id'},
228+
}
229+
230+
response = client.post('/', json=request_payload)
231+
assert response.status_code == 200
232+
data = response.json()
233+
234+
assert data['id'] == '5'
235+
assert data['error']['code'] == expected_code
236+
assert data['error']['message'] == error.message
237+
238+
239+
def test_message_stream_v03_compat_maps_a2a_error_code(
240+
client: TestClient, mock_handler: AsyncMock
241+
) -> None:
242+
async def failing_stream(
243+
*args: object, **kwargs: object
244+
) -> AsyncIterator[MagicMock]:
245+
raise TaskNotFoundError
246+
yield MagicMock()
247+
248+
mock_handler.on_message_send_stream = failing_stream
249+
request_payload = {
250+
'jsonrpc': '2.0',
251+
'id': '6',
252+
'method': 'message/stream',
253+
'params': {
254+
'message': {
255+
'messageId': 'req',
256+
'role': 'user',
257+
'parts': [{'text': 'hello'}],
258+
}
259+
},
260+
}
261+
262+
response = client.post('/', json=request_payload)
263+
assert response.status_code == 200
264+
events = [
265+
json.loads(line.removeprefix('data:'))
266+
for line in response.text.splitlines()
267+
if line.startswith('data:')
268+
]
269+
270+
assert len(events) == 1
271+
assert events[0]['id'] == '6'
272+
assert events[0]['error']['code'] == -32001
273+
assert events[0]['error']['message'] == 'Task not found'

0 commit comments

Comments
 (0)