From eee306f4b9a5287ce13d40969f582d12be372919 Mon Sep 17 00:00:00 2001 From: Dmitry Voropaev Date: Thu, 29 Oct 2026 12:10:23 +0300 Subject: [PATCH] fix(compat): map google.rpc.Status errors in the v0.3 REST client CompatRestTransport only turned an error body into an A2A error when it looked like {"type": "TaskNotFoundError", "message": ...}. The v0.3 REST routes in this SDK (create_rest_routes with enable_v0_3_compat) answer with a google.rpc.Status body carrying an ErrorInfo reason, the same as the v1.0 binding. So a missing task came back as MethodNotFoundError and an uncancelable task as a bare A2AClientError, while the v0.3 JSON-RPC client and the v1.0 REST client raise TaskNotFoundError and TaskNotCancelableError for the same server. Run the body through _parse_rest_error from the v1.0 REST transport first, so every reason in A2A_REASON_TO_ERROR is mapped. The old "type" lookup and the status code fallback are kept after it. --- src/a2a/compat/v0_3/rest_transport.py | 7 ++ tests/compat/v0_3/test_rest_transport.py | 109 ++++++++++++++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/a2a/compat/v0_3/rest_transport.py b/src/a2a/compat/v0_3/rest_transport.py index 362946736..979c93690 100644 --- a/src/a2a/compat/v0_3/rest_transport.py +++ b/src/a2a/compat/v0_3/rest_transport.py @@ -17,6 +17,7 @@ send_http_request, send_http_stream_request, ) +from a2a.client.transports.rest import _parse_rest_error from a2a.compat.v0_3 import ( a2a_v0_3_pb2, conversions, @@ -368,6 +369,12 @@ def _handle_http_error(self, e: httpx.HTTPStatusError) -> NoReturn: if not isinstance(error_data, dict): error_data = {} + # Servers built on this SDK answer v0.3 REST requests with a + # google.rpc.Status body, the same as the v1.0 REST binding. + mapped = _parse_rest_error(error_data, str(e)) + if mapped: + raise mapped from e + error_type = error_data.get('type') message = error_data.get('message', str(e)) diff --git a/tests/compat/v0_3/test_rest_transport.py b/tests/compat/v0_3/test_rest_transport.py index e627b444f..0e06235eb 100644 --- a/tests/compat/v0_3/test_rest_transport.py +++ b/tests/compat/v0_3/test_rest_transport.py @@ -7,6 +7,8 @@ from a2a.client.errors import A2AClientError from a2a.compat.v0_3.rest_transport import CompatRestTransport +from a2a.server.request_handlers.request_handler import RequestHandler +from a2a.server.routes.rest_routes import create_rest_routes from a2a.types.a2a_pb2 import ( AgentCapabilities, AgentCard, @@ -27,7 +29,13 @@ TaskPushNotificationConfig, TaskStatus, ) -from a2a.utils.errors import InvalidParamsError, MethodNotFoundError +from a2a.utils.errors import ( + InvalidParamsError, + MethodNotFoundError, + TaskNotCancelableError, + TaskNotFoundError, +) +from starlette.applications import Starlette @pytest.fixture @@ -633,6 +641,105 @@ def test_compat_rest_transport_handle_http_error_unknown_error_type(transport): transport._handle_http_error(error) +@pytest.mark.parametrize( + ('status_code', 'status', 'reason', 'message', 'expected_error'), + [ + ( + 404, + 'NOT_FOUND', + 'TASK_NOT_FOUND', + 'Task not found', + TaskNotFoundError, + ), + ( + 400, + 'FAILED_PRECONDITION', + 'TASK_NOT_CANCELABLE', + 'Task cannot be canceled', + TaskNotCancelableError, + ), + ], +) +def test_compat_rest_transport_handle_http_error_google_rpc_status( + transport, status_code, status, reason, message, expected_error +): + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = status_code + mock_response.json.return_value = { + 'error': { + 'code': status_code, + 'status': status, + 'message': message, + 'details': [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + 'reason': reason, + 'domain': 'a2a-protocol.org', + 'metadata': {}, + } + ], + } + } + + mock_request = MagicMock(spec=httpx.Request) + mock_request.url = 'http://example.com' + + error = httpx.HTTPStatusError( + 'Error', request=mock_request, response=mock_response + ) + + with pytest.raises(expected_error) as exc_info: + transport._handle_http_error(error) + + assert str(exc_info.value) == message + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('handler_method', 'handler_error', 'call'), + [ + ( + 'on_get_task', + TaskNotFoundError, + lambda t: t.get_task(GetTaskRequest(id='missing')), + ), + ( + 'on_cancel_task', + TaskNotFoundError, + lambda t: t.cancel_task(CancelTaskRequest(id='missing')), + ), + ( + 'on_cancel_task', + TaskNotCancelableError, + lambda t: t.cancel_task(CancelTaskRequest(id='done')), + ), + ], + ids=['get_missing', 'cancel_missing', 'cancel_completed'], +) +async def test_compat_rest_transport_maps_errors_from_v03_compat_server( + agent_card, handler_method, handler_error, call +): + request_handler = MagicMock(spec=RequestHandler) + getattr(request_handler, handler_method).side_effect = handler_error() + app = Starlette( + routes=create_rest_routes( + request_handler=request_handler, enable_v0_3_compat=True + ) + ) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url='http://testserver' + ) as httpx_client: + transport = CompatRestTransport( + httpx_client=httpx_client, + agent_card=agent_card, + url='http://testserver', + ) + + with pytest.raises(handler_error): + await call(transport) + + @pytest.mark.asyncio @patch('a2a.compat.v0_3.rest_transport.send_http_stream_request') async def test_compat_rest_transport_send_stream_request(