From aef5da45ad0b9ee54f47d6ff33f38a3700fc0063 Mon Sep 17 00:00:00 2001 From: sokoliva Date: Thu, 7 May 2026 08:44:57 +0000 Subject: [PATCH 1/4] fix(errors): emit JSON-RPC error.data as typed-details array per A2A 1.0 spec hot-fix --- src/a2a/client/transports/jsonrpc.py | 15 +++- .../request_handlers/response_helpers.py | 3 +- src/a2a/utils/error_handlers.py | 35 ++++---- src/a2a/utils/errors.py | 60 ++++++++++++- .../client/transports/test_jsonrpc_client.py | 84 +++++++++++++++++++ .../request_handlers/test_response_helpers.py | 72 ++++++++++++++++ tests/utils/test_error_handlers.py | 60 +++++++++++++ 7 files changed, 307 insertions(+), 22 deletions(-) diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py index 252ea439d..c18232ab0 100644 --- a/src/a2a/client/transports/jsonrpc.py +++ b/src/a2a/client/transports/jsonrpc.py @@ -35,7 +35,7 @@ Task, TaskPushNotificationConfig, ) -from a2a.utils.errors import JSON_RPC_ERROR_CODE_MAP +from a2a.utils.errors import ERROR_INFO_TYPE, JSON_RPC_ERROR_CODE_MAP from a2a.utils.telemetry import SpanKind, trace_class @@ -318,10 +318,19 @@ def _create_jsonrpc_error(self, error_dict: dict[str, Any]) -> Exception: """Creates the appropriate A2AError from a JSON-RPC error dictionary.""" code = error_dict.get('code') message = error_dict.get('message', str(error_dict)) - data = error_dict.get('data') + raw_data = error_dict.get('data') + + a2a_data: dict[str, Any] | None = None + if isinstance(raw_data, list): + for d in raw_data: + if isinstance(d, dict) and d.get('@type') == ERROR_INFO_TYPE: + a2a_data = d.get('metadata') or None + break if isinstance(code, int) and code in _JSON_RPC_ERROR_CODE_TO_A2A_ERROR: - return _JSON_RPC_ERROR_CODE_TO_A2A_ERROR[code](message, data=data) + return _JSON_RPC_ERROR_CODE_TO_A2A_ERROR[code]( + message, data=a2a_data + ) # Fallback to general A2AClientError return A2AClientError(f'JSON-RPC Error {code}: {message}') diff --git a/src/a2a/server/request_handlers/response_helpers.py b/src/a2a/server/request_handlers/response_helpers.py index 15a0c5263..dae0cc872 100644 --- a/src/a2a/server/request_handlers/response_helpers.py +++ b/src/a2a/server/request_handlers/response_helpers.py @@ -42,6 +42,7 @@ TaskNotFoundError, UnsupportedOperationError, VersionNotSupportedError, + build_error_details, ) @@ -135,7 +136,7 @@ def build_error_response( jsonrpc_error = model_class( code=code, message=str(error), - data=error.data, + data=build_error_details(error), ) else: jsonrpc_error = JSONRPCInternalError(message=str(error)) diff --git a/src/a2a/utils/error_handlers.py b/src/a2a/utils/error_handlers.py index ea544d79d..85dcdab84 100644 --- a/src/a2a/utils/error_handlers.py +++ b/src/a2a/utils/error_handlers.py @@ -19,10 +19,13 @@ from google.protobuf.json_format import ParseError from a2a.utils.errors import ( + A2A_DOMAIN, A2A_REST_ERROR_MAPPING, + ERROR_INFO_TYPE, A2AError, InternalError, RestErrorMap, + build_error_details, ) @@ -33,8 +36,7 @@ def _build_error_payload( code: int, status: str, message: str, - reason: str | None = None, - metadata: dict[str, Any] | None = None, + details: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """Helper function to build the JSON error payload.""" payload: dict[str, Any] = { @@ -42,15 +44,8 @@ def _build_error_payload( 'status': status, 'message': message, } - if reason: - payload['details'] = [ - { - '@type': 'type.googleapis.com/google.rpc.ErrorInfo', - 'reason': reason, - 'domain': 'a2a-protocol.org', - 'metadata': metadata if metadata is not None else {}, - } - ] + if details: + payload['details'] = details return {'error': payload} @@ -64,22 +59,28 @@ def build_rest_error_payload(error: Exception) -> dict[str, Any]: mapping = A2A_REST_ERROR_MAPPING.get( type(error), RestErrorMap(500, 'INTERNAL', 'INTERNAL_ERROR') ) - # SECURITY WARNING: Data attached to A2AError.data is serialized unaltered and exposed publicly to the client in the REST API response. - metadata = getattr(error, 'data', None) or {} + # SECURITY WARNING: Data attached to A2AError.data is serialized + # unaltered and exposed publicly to the client in the REST API + # response (as ErrorInfo.metadata). return _build_error_payload( code=mapping.http_code, status=mapping.grpc_status, message=getattr(error, 'message', str(error)), - reason=mapping.reason, - metadata=metadata, + details=build_error_details(error), ) if isinstance(error, ParseError): return _build_error_payload( code=400, status='INVALID_ARGUMENT', message=str(error), - reason='INVALID_REQUEST', - metadata={}, + details=[ + { + '@type': ERROR_INFO_TYPE, + 'reason': 'INVALID_REQUEST', + 'domain': A2A_DOMAIN, + 'metadata': {}, + } + ], ) return _build_error_payload( code=500, diff --git a/src/a2a/utils/errors.py b/src/a2a/utils/errors.py index a09d3b45b..1987b0f95 100644 --- a/src/a2a/utils/errors.py +++ b/src/a2a/utils/errors.py @@ -4,7 +4,13 @@ as well as server exception classes. """ -from typing import NamedTuple +from typing import Any, NamedTuple + +from google.protobuf.json_format import MessageToDict + +from a2a.utils.proto_utils import ( + validation_errors_to_bad_request, +) class RestErrorMap(NamedTuple): @@ -94,6 +100,12 @@ class MethodNotFoundError(A2AError): message = 'Method not found' +class JSONParseError(A2AError): + """Exception raised when invalid JSON was received by the server.""" + + message = 'Invalid JSON payload' + + class ExtensionSupportRequiredError(A2AError): """Exception raised when extension support is required but not present.""" @@ -119,6 +131,7 @@ class VersionNotSupportedError(A2AError): 'InvalidAgentResponseError', 'InvalidParamsError', 'InvalidRequestError', + 'JSONParseError', 'MethodNotFoundError', 'PushNotificationNotSupportedError', 'RestErrorMap', @@ -143,6 +156,7 @@ class VersionNotSupportedError(A2AError): InvalidRequestError: -32600, MethodNotFoundError: -32601, InternalError: -32603, + JSONParseError: -32700, } @@ -196,3 +210,47 @@ class VersionNotSupportedError(A2AError): A2A_REASON_TO_ERROR = { mapping.reason: cls for cls, mapping in A2A_REST_ERROR_MAPPING.items() } + + +ERROR_INFO_TYPE = 'type.googleapis.com/google.rpc.ErrorInfo' +BAD_REQUEST_TYPE = 'type.googleapis.com/google.rpc.BadRequest' +A2A_DOMAIN = 'a2a-protocol.org' + + +def build_error_details(error: A2AError) -> list[dict[str, Any]]: + """Build the typed-details array for an A2AError. + + Always emits a leading google.rpc.ErrorInfo carrying the canonical + A2A reason and error.data as metadata. For InvalidParamsError whose + data contains an errors list of validation details, also appends a + google.rpc.BadRequest so all transports surface field-level violations + identically. + """ + reason = A2A_ERROR_REASONS.get(type(error), 'UNKNOWN_ERROR') + metadata = error.data if isinstance(error.data, dict) else {} + details: list[dict[str, Any]] = [ + { + '@type': ERROR_INFO_TYPE, + 'reason': reason, + 'domain': A2A_DOMAIN, + 'metadata': metadata, + } + ] + + if ( + isinstance(error, InvalidParamsError) + and isinstance(error.data, dict) + and error.data.get('errors') + ): + bad_request_dict = MessageToDict( + validation_errors_to_bad_request(error.data['errors']), + preserving_proto_field_name=False, + ) + details.append( + { + '@type': BAD_REQUEST_TYPE, + 'fieldViolations': bad_request_dict.get('fieldViolations', []), + } + ) + + return details diff --git a/tests/client/transports/test_jsonrpc_client.py b/tests/client/transports/test_jsonrpc_client.py index f9624a7c4..85c89b62e 100644 --- a/tests/client/transports/test_jsonrpc_client.py +++ b/tests/client/transports/test_jsonrpc_client.py @@ -684,3 +684,87 @@ async def test_get_card_with_extended_card_support_with_extensions( assert mock_kwargs.service_parameters == { HTTP_EXTENSION_HEADER: extensions_header_val } + + +class TestCreateJsonRpcError: + """Unit tests for JsonRpcTransport._create_jsonrpc_error.""" + + @pytest.fixture + def transport(self, mock_httpx_client, agent_card): + return JsonRpcTransport( + httpx_client=mock_httpx_client, + agent_card=agent_card, + url='http://test-agent.example.com', + ) + + def test_lifts_error_info_metadata_onto_a2a_error_data( + self, transport + ) -> None: + """New spec format: ErrorInfo.metadata is exposed as A2AError.data.""" + from a2a.utils.errors import TaskNotFoundError + + exc = transport._create_jsonrpc_error( + { + 'code': -32001, + 'message': 'Task not found', + 'data': [ + { + '@type': ('type.googleapis.com/google.rpc.ErrorInfo'), + 'reason': 'TASK_NOT_FOUND', + 'domain': 'a2a-protocol.org', + 'metadata': {'taskId': 'abc-123'}, + } + ], + } + ) + assert isinstance(exc, TaskNotFoundError) + assert exc.message == 'Task not found' + assert exc.data == {'taskId': 'abc-123'} + + def test_no_data_field_yields_none_data(self, transport) -> None: + from a2a.utils.errors import InternalError + + exc = transport._create_jsonrpc_error( + {'code': -32603, 'message': 'oops'} + ) + assert isinstance(exc, InternalError) + assert exc.data is None + + def test_array_without_error_info_yields_none_data(self, transport) -> None: + """A details array carrying only BadRequest (no ErrorInfo) yields None.""" + from a2a.utils.errors import InvalidParamsError + + exc = transport._create_jsonrpc_error( + { + 'code': -32602, + 'message': 'bad params', + 'data': [ + { + '@type': ('type.googleapis.com/google.rpc.BadRequest'), + 'fieldViolations': [], + } + ], + } + ) + assert isinstance(exc, InvalidParamsError) + assert exc.data is None + + def test_unknown_code_falls_back_to_a2a_client_error( + self, transport + ) -> None: + exc = transport._create_jsonrpc_error( + {'code': -42, 'message': 'who knows'} + ) + assert isinstance(exc, A2AClientError) + assert 'JSON-RPC Error -42' in str(exc) + + def test_json_parse_error_is_typed(self, transport) -> None: + """JSON-RPC -32700 must map to a typed JSONParseError exception (not + the generic A2AClientError fallback).""" + from a2a.utils.errors import JSONParseError + + exc = transport._create_jsonrpc_error( + {'code': -32700, 'message': 'Invalid JSON payload'} + ) + assert isinstance(exc, JSONParseError) + assert exc.message == 'Invalid JSON payload' diff --git a/tests/server/request_handlers/test_response_helpers.py b/tests/server/request_handlers/test_response_helpers.py index 372558c08..2e7caeba3 100644 --- a/tests/server/request_handlers/test_response_helpers.py +++ b/tests/server/request_handlers/test_response_helpers.py @@ -258,6 +258,78 @@ def test_build_error_response_with_invalid_params_error(self) -> None: response['error']['message'], specific_jsonrpc_error.message ) + def test_build_error_response_data_is_typed_details_array(self) -> None: + """error.data must be an array of typed-detail objects, with a + leading google.rpc.ErrorInfo carrying the canonical reason.""" + error = TaskNotFoundError(data={'taskId': 'abc-123'}) + response = build_error_response('req-typed', error) + + data = response['error']['data'] + self.assertIsInstance(data, list) + self.assertEqual(len(data), 1) + self.assertEqual( + data[0], + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + 'reason': 'TASK_NOT_FOUND', + 'domain': 'a2a-protocol.org', + 'metadata': {'taskId': 'abc-123'}, + }, + ) + + def test_build_error_response_invalid_params_appends_bad_request( + self, + ) -> None: + """InvalidParamsError carrying validation errors must append a + BadRequest typed-detail entry alongside ErrorInfo.""" + error = InvalidParamsError( + message='Validation failed', + data={ + 'errors': [ + { + 'field': 'message.parts', + 'message': 'At least one required', + }, + {'field': 'message.role', 'message': 'Unknown role'}, + ] + }, + ) + response = build_error_response('req-bad', error) + + data = response['error']['data'] + self.assertEqual(len(data), 2) + error_info, bad_request = data + self.assertEqual( + error_info['@type'], + 'type.googleapis.com/google.rpc.ErrorInfo', + ) + self.assertEqual(error_info['reason'], 'INVALID_PARAMS') + self.assertEqual( + bad_request, + { + '@type': 'type.googleapis.com/google.rpc.BadRequest', + 'fieldViolations': [ + { + 'field': 'message.parts', + 'description': 'At least one required', + }, + { + 'field': 'message.role', + 'description': 'Unknown role', + }, + ], + }, + ) + + def test_build_error_response_no_data_yields_empty_metadata(self) -> None: + """An A2AError with no data still produces a single + ErrorInfo entry with an empty metadata dict (per AIP-193).""" + error = TaskNotFoundError() + response = build_error_response('req-empty', error) + data = response['error']['data'] + self.assertEqual(len(data), 1) + self.assertEqual(data[0]['metadata'], {}) + def test_build_error_response_with_request_id_string(self) -> None: request_id = 'string_id_test' error = TaskNotFoundError() diff --git a/tests/utils/test_error_handlers.py b/tests/utils/test_error_handlers.py index 93ad6a7c0..3c30b09b7 100644 --- a/tests/utils/test_error_handlers.py +++ b/tests/utils/test_error_handlers.py @@ -10,10 +10,12 @@ InternalError, ) from a2a.utils.error_handlers import ( + build_rest_error_payload, rest_error_handler, rest_stream_error_handler, ) from a2a.utils.errors import ( + InvalidParamsError, InvalidRequestError, ) @@ -188,3 +190,61 @@ async def successful_stream(): result = await successful_stream() assert result == 'success_stream' + + +def test_build_rest_error_payload_invalid_params_includes_bad_request(): + """REST error payload for InvalidParamsError with validation errors must + include both an ErrorInfo and a BadRequest typed-detail entry, matching + the gRPC Status.details shape (see a2aproject/A2A#1627).""" + error = InvalidParamsError( + message='Validation failed', + data={ + 'errors': [ + {'field': 'message.parts', 'message': 'At least one required'}, + {'field': 'message.role', 'message': 'Unknown role'}, + ] + }, + ) + + payload = build_rest_error_payload(error) + + assert payload['error']['code'] == 400 + assert payload['error']['status'] == 'INVALID_ARGUMENT' + assert payload['error']['message'] == 'Validation failed' + + details = payload['error']['details'] + assert len(details) == 2 + + error_info, bad_request = details + assert error_info == { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + 'reason': 'INVALID_PARAMS', + 'domain': 'a2a-protocol.org', + 'metadata': { + 'errors': [ + {'field': 'message.parts', 'message': 'At least one required'}, + {'field': 'message.role', 'message': 'Unknown role'}, + ] + }, + } + assert bad_request == { + '@type': 'type.googleapis.com/google.rpc.BadRequest', + 'fieldViolations': [ + { + 'field': 'message.parts', + 'description': 'At least one required', + }, + {'field': 'message.role', 'description': 'Unknown role'}, + ], + } + + +def test_build_rest_error_payload_invalid_params_no_validation_errors(): + """InvalidParamsError without ``data['errors']`` must not append a + BadRequest entry — only the canonical ErrorInfo.""" + error = InvalidParamsError(message='Bad params') + payload = build_rest_error_payload(error) + assert len(payload['error']['details']) == 1 + assert payload['error']['details'][0]['@type'] == ( + 'type.googleapis.com/google.rpc.ErrorInfo' + ) From f0cf08bc79f6b95279288e33645239ebaccaaafd Mon Sep 17 00:00:00 2001 From: sokoliva Date: Thu, 7 May 2026 09:13:34 +0000 Subject: [PATCH 2/4] fix --- src/a2a/client/transports/jsonrpc.py | 5 +- .../request_handlers/response_helpers.py | 2 +- src/a2a/utils/error_handlers.py | 71 +++++++++++++++---- src/a2a/utils/errors.py | 52 +------------- 4 files changed, 64 insertions(+), 66 deletions(-) diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py index c18232ab0..b6a687998 100644 --- a/src/a2a/client/transports/jsonrpc.py +++ b/src/a2a/client/transports/jsonrpc.py @@ -35,7 +35,7 @@ Task, TaskPushNotificationConfig, ) -from a2a.utils.errors import ERROR_INFO_TYPE, JSON_RPC_ERROR_CODE_MAP +from a2a.utils.errors import JSON_RPC_ERROR_CODE_MAP from a2a.utils.telemetry import SpanKind, trace_class @@ -44,6 +44,7 @@ _JSON_RPC_ERROR_CODE_TO_A2A_ERROR = { code: error_type for error_type, code in JSON_RPC_ERROR_CODE_MAP.items() } +_ERROR_INFO_TYPE = 'type.googleapis.com/google.rpc.ErrorInfo' @trace_class(kind=SpanKind.CLIENT) @@ -323,7 +324,7 @@ def _create_jsonrpc_error(self, error_dict: dict[str, Any]) -> Exception: a2a_data: dict[str, Any] | None = None if isinstance(raw_data, list): for d in raw_data: - if isinstance(d, dict) and d.get('@type') == ERROR_INFO_TYPE: + if isinstance(d, dict) and d.get('@type') == _ERROR_INFO_TYPE: a2a_data = d.get('metadata') or None break diff --git a/src/a2a/server/request_handlers/response_helpers.py b/src/a2a/server/request_handlers/response_helpers.py index dae0cc872..d41bab1d9 100644 --- a/src/a2a/server/request_handlers/response_helpers.py +++ b/src/a2a/server/request_handlers/response_helpers.py @@ -26,6 +26,7 @@ from a2a.types.a2a_pb2 import ( SendMessageResponse as SendMessageResponseProto, ) +from a2a.utils.error_handlers import build_error_details from a2a.utils.errors import ( JSON_RPC_ERROR_CODE_MAP, A2AError, @@ -42,7 +43,6 @@ TaskNotFoundError, UnsupportedOperationError, VersionNotSupportedError, - build_error_details, ) diff --git a/src/a2a/utils/error_handlers.py b/src/a2a/utils/error_handlers.py index 85dcdab84..6944b054c 100644 --- a/src/a2a/utils/error_handlers.py +++ b/src/a2a/utils/error_handlers.py @@ -16,22 +16,76 @@ Response = Any -from google.protobuf.json_format import ParseError +from google.protobuf.json_format import MessageToDict, ParseError from a2a.utils.errors import ( - A2A_DOMAIN, + A2A_ERROR_REASONS, A2A_REST_ERROR_MAPPING, - ERROR_INFO_TYPE, A2AError, InternalError, + InvalidParamsError, RestErrorMap, - build_error_details, ) +from a2a.utils.proto_utils import validation_errors_to_bad_request logger = logging.getLogger(__name__) +ERROR_INFO_TYPE = 'type.googleapis.com/google.rpc.ErrorInfo' +BAD_REQUEST_TYPE = 'type.googleapis.com/google.rpc.BadRequest' +A2A_DOMAIN = 'a2a-protocol.org' + + +def _error_info( + reason: str, metadata: dict[str, Any] | None = None +) -> dict[str, Any]: + """Build a single ``google.rpc.ErrorInfo`` typed-detail object.""" + return { + '@type': ERROR_INFO_TYPE, + 'reason': reason, + 'domain': A2A_DOMAIN, + 'metadata': metadata if metadata is not None else {}, + } + + +def build_error_details(error: A2AError) -> list[dict[str, Any]]: + """Build the typed-details array for an :class:`A2AError`. + + Always emits a leading ``google.rpc.ErrorInfo`` carrying the canonical + A2A reason and ``error.data`` as ``metadata``. For + :class:`InvalidParamsError` whose ``data`` contains an ``errors`` list of + validation details, also appends a ``google.rpc.BadRequest`` so all + transports surface field-level violations identically. + + Note: + ``error.data`` is serialized verbatim into the ``ErrorInfo.metadata`` + field and exposed publicly to the client. Callers must not attach + sensitive information to ``A2AError.data``. + """ + reason = A2A_ERROR_REASONS.get(type(error), 'UNKNOWN_ERROR') + metadata = error.data if isinstance(error.data, dict) else {} + details: list[dict[str, Any]] = [_error_info(reason, metadata)] + + if ( + isinstance(error, InvalidParamsError) + and isinstance(error.data, dict) + and error.data.get('errors') + ): + bad_request_dict = MessageToDict( + validation_errors_to_bad_request(error.data['errors']), + preserving_proto_field_name=False, + ) + details.append( + { + '@type': BAD_REQUEST_TYPE, + 'fieldViolations': bad_request_dict.get('fieldViolations', []), + } + ) + + return details + + def _build_error_payload( code: int, status: str, @@ -73,14 +127,7 @@ def build_rest_error_payload(error: Exception) -> dict[str, Any]: code=400, status='INVALID_ARGUMENT', message=str(error), - details=[ - { - '@type': ERROR_INFO_TYPE, - 'reason': 'INVALID_REQUEST', - 'domain': A2A_DOMAIN, - 'metadata': {}, - } - ], + details=[_error_info('INVALID_REQUEST')], ) return _build_error_payload( code=500, diff --git a/src/a2a/utils/errors.py b/src/a2a/utils/errors.py index 1987b0f95..544ca68e8 100644 --- a/src/a2a/utils/errors.py +++ b/src/a2a/utils/errors.py @@ -4,13 +4,7 @@ as well as server exception classes. """ -from typing import Any, NamedTuple - -from google.protobuf.json_format import MessageToDict - -from a2a.utils.proto_utils import ( - validation_errors_to_bad_request, -) +from typing import NamedTuple class RestErrorMap(NamedTuple): @@ -210,47 +204,3 @@ class VersionNotSupportedError(A2AError): A2A_REASON_TO_ERROR = { mapping.reason: cls for cls, mapping in A2A_REST_ERROR_MAPPING.items() } - - -ERROR_INFO_TYPE = 'type.googleapis.com/google.rpc.ErrorInfo' -BAD_REQUEST_TYPE = 'type.googleapis.com/google.rpc.BadRequest' -A2A_DOMAIN = 'a2a-protocol.org' - - -def build_error_details(error: A2AError) -> list[dict[str, Any]]: - """Build the typed-details array for an A2AError. - - Always emits a leading google.rpc.ErrorInfo carrying the canonical - A2A reason and error.data as metadata. For InvalidParamsError whose - data contains an errors list of validation details, also appends a - google.rpc.BadRequest so all transports surface field-level violations - identically. - """ - reason = A2A_ERROR_REASONS.get(type(error), 'UNKNOWN_ERROR') - metadata = error.data if isinstance(error.data, dict) else {} - details: list[dict[str, Any]] = [ - { - '@type': ERROR_INFO_TYPE, - 'reason': reason, - 'domain': A2A_DOMAIN, - 'metadata': metadata, - } - ] - - if ( - isinstance(error, InvalidParamsError) - and isinstance(error.data, dict) - and error.data.get('errors') - ): - bad_request_dict = MessageToDict( - validation_errors_to_bad_request(error.data['errors']), - preserving_proto_field_name=False, - ) - details.append( - { - '@type': BAD_REQUEST_TYPE, - 'fieldViolations': bad_request_dict.get('fieldViolations', []), - } - ) - - return details From 125a4713561d4cc0c4f698584fdbb8ddb442486f Mon Sep 17 00:00:00 2001 From: sokoliva Date: Thu, 7 May 2026 09:17:48 +0000 Subject: [PATCH 3/4] fix --- src/a2a/utils/error_handlers.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/a2a/utils/error_handlers.py b/src/a2a/utils/error_handlers.py index 6944b054c..060cb1eab 100644 --- a/src/a2a/utils/error_handlers.py +++ b/src/a2a/utils/error_handlers.py @@ -52,16 +52,11 @@ def _error_info( def build_error_details(error: A2AError) -> list[dict[str, Any]]: """Build the typed-details array for an :class:`A2AError`. - Always emits a leading ``google.rpc.ErrorInfo`` carrying the canonical - A2A reason and ``error.data`` as ``metadata``. For - :class:`InvalidParamsError` whose ``data`` contains an ``errors`` list of - validation details, also appends a ``google.rpc.BadRequest`` so all - transports surface field-level violations identically. - - Note: - ``error.data`` is serialized verbatim into the ``ErrorInfo.metadata`` - field and exposed publicly to the client. Callers must not attach - sensitive information to ``A2AError.data``. + Always emits a leading ``google.rpc.ErrorInfo`` carrying the A2A reason + and ``error.data`` as ``metadata``. For `InvalidParamsError` whose ``data`` + contains an ``errors`` list of validation details, also appends + a ``google.rpc.BadRequest`` so all transports surface field-level + violations identically. """ reason = A2A_ERROR_REASONS.get(type(error), 'UNKNOWN_ERROR') metadata = error.data if isinstance(error.data, dict) else {} From 58dcd9e0fe9cfc4056c298ad2b1d72f1dfe325b2 Mon Sep 17 00:00:00 2001 From: sokoliva Date: Thu, 7 May 2026 10:06:13 +0000 Subject: [PATCH 4/4] rename --- src/a2a/utils/error_handlers.py | 8 ++-- src/a2a/utils/errors.py | 45 +++++++++++---------- tests/client/transports/test_rest_client.py | 6 +-- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/a2a/utils/error_handlers.py b/src/a2a/utils/error_handlers.py index 060cb1eab..ef8182940 100644 --- a/src/a2a/utils/error_handlers.py +++ b/src/a2a/utils/error_handlers.py @@ -19,12 +19,12 @@ from google.protobuf.json_format import MessageToDict, ParseError from a2a.utils.errors import ( + A2A_ERROR_MAPPING, A2A_ERROR_REASONS, - A2A_REST_ERROR_MAPPING, A2AError, + ErrorMapping, InternalError, InvalidParamsError, - RestErrorMap, ) from a2a.utils.proto_utils import validation_errors_to_bad_request @@ -105,8 +105,8 @@ def build_rest_error_payload(error: Exception) -> dict[str, Any]: A dict with the error payload in the standard REST error format. """ if isinstance(error, A2AError): - mapping = A2A_REST_ERROR_MAPPING.get( - type(error), RestErrorMap(500, 'INTERNAL', 'INTERNAL_ERROR') + mapping = A2A_ERROR_MAPPING.get( + type(error), ErrorMapping(500, 'INTERNAL', 'INTERNAL_ERROR') ) # SECURITY WARNING: Data attached to A2AError.data is serialized # unaltered and exposed publicly to the client in the REST API diff --git a/src/a2a/utils/errors.py b/src/a2a/utils/errors.py index 544ca68e8..b55f47070 100644 --- a/src/a2a/utils/errors.py +++ b/src/a2a/utils/errors.py @@ -7,7 +7,7 @@ from typing import NamedTuple -class RestErrorMap(NamedTuple): +class ErrorMapping(NamedTuple): """Named tuple mapping HTTP status, gRPC status, and reason strings.""" http_code: int @@ -112,14 +112,13 @@ class VersionNotSupportedError(A2AError): message = 'Version not supported' -# For backward compatibility if needed, or just aliases for clean refactor -# We remove the Pydantic models here. - __all__ = [ + 'A2A_ERROR_MAPPING', 'A2A_ERROR_REASONS', 'A2A_REASON_TO_ERROR', 'A2A_REST_ERROR_MAPPING', 'JSON_RPC_ERROR_CODE_MAP', + 'ErrorMapping', 'ExtensionSupportRequiredError', 'InternalError', 'InvalidAgentResponseError', @@ -128,7 +127,6 @@ class VersionNotSupportedError(A2AError): 'JSONParseError', 'MethodNotFoundError', 'PushNotificationNotSupportedError', - 'RestErrorMap', 'TaskNotCancelableError', 'TaskNotFoundError', 'UnsupportedOperationError', @@ -154,53 +152,58 @@ class VersionNotSupportedError(A2AError): } -A2A_REST_ERROR_MAPPING: dict[type[A2AError], RestErrorMap] = { - TaskNotFoundError: RestErrorMap(404, 'NOT_FOUND', 'TASK_NOT_FOUND'), - TaskNotCancelableError: RestErrorMap( +A2A_ERROR_MAPPING: dict[type[A2AError], ErrorMapping] = { + TaskNotFoundError: ErrorMapping(404, 'NOT_FOUND', 'TASK_NOT_FOUND'), + TaskNotCancelableError: ErrorMapping( 400, 'FAILED_PRECONDITION', 'TASK_NOT_CANCELABLE' ), - PushNotificationNotSupportedError: RestErrorMap( + PushNotificationNotSupportedError: ErrorMapping( 400, 'FAILED_PRECONDITION', 'PUSH_NOTIFICATION_NOT_SUPPORTED', ), - UnsupportedOperationError: RestErrorMap( + UnsupportedOperationError: ErrorMapping( 400, 'FAILED_PRECONDITION', 'UNSUPPORTED_OPERATION' ), - ContentTypeNotSupportedError: RestErrorMap( + ContentTypeNotSupportedError: ErrorMapping( 400, 'INVALID_ARGUMENT', 'CONTENT_TYPE_NOT_SUPPORTED', ), - InvalidAgentResponseError: RestErrorMap( + InvalidAgentResponseError: ErrorMapping( 500, 'INTERNAL', 'INVALID_AGENT_RESPONSE' ), - ExtendedAgentCardNotConfiguredError: RestErrorMap( + ExtendedAgentCardNotConfiguredError: ErrorMapping( 400, 'FAILED_PRECONDITION', 'EXTENDED_AGENT_CARD_NOT_CONFIGURED', ), - ExtensionSupportRequiredError: RestErrorMap( + ExtensionSupportRequiredError: ErrorMapping( 400, 'FAILED_PRECONDITION', 'EXTENSION_SUPPORT_REQUIRED', ), - VersionNotSupportedError: RestErrorMap( + VersionNotSupportedError: ErrorMapping( 400, 'FAILED_PRECONDITION', 'VERSION_NOT_SUPPORTED' ), - InvalidParamsError: RestErrorMap(400, 'INVALID_ARGUMENT', 'INVALID_PARAMS'), - InvalidRequestError: RestErrorMap( + InvalidParamsError: ErrorMapping(400, 'INVALID_ARGUMENT', 'INVALID_PARAMS'), + InvalidRequestError: ErrorMapping( 400, 'INVALID_ARGUMENT', 'INVALID_REQUEST' ), - MethodNotFoundError: RestErrorMap(404, 'NOT_FOUND', 'METHOD_NOT_FOUND'), - InternalError: RestErrorMap(500, 'INTERNAL', 'INTERNAL_ERROR'), + MethodNotFoundError: ErrorMapping(404, 'NOT_FOUND', 'METHOD_NOT_FOUND'), + InternalError: ErrorMapping(500, 'INTERNAL', 'INTERNAL_ERROR'), } +# Deprecated alias kept for backwards compatibility; remove in the next +# major version. +A2A_REST_ERROR_MAPPING = A2A_ERROR_MAPPING + + A2A_ERROR_REASONS = { - cls: mapping.reason for cls, mapping in A2A_REST_ERROR_MAPPING.items() + cls: mapping.reason for cls, mapping in A2A_ERROR_MAPPING.items() } A2A_REASON_TO_ERROR = { - mapping.reason: cls for cls, mapping in A2A_REST_ERROR_MAPPING.items() + mapping.reason: cls for cls, mapping in A2A_ERROR_MAPPING.items() } diff --git a/tests/client/transports/test_rest_client.py b/tests/client/transports/test_rest_client.py index ae01a128c..f7281c06a 100644 --- a/tests/client/transports/test_rest_client.py +++ b/tests/client/transports/test_rest_client.py @@ -26,7 +26,7 @@ TaskState, ) from a2a.utils.constants import TransportProtocol -from a2a.utils.errors import A2A_REST_ERROR_MAPPING +from a2a.utils.errors import A2A_ERROR_MAPPING from google.protobuf import json_format from google.protobuf.timestamp_pb2 import Timestamp from httpx_sse import EventSource, ServerSentEvent @@ -105,7 +105,7 @@ async def test_send_message_streaming_timeout( assert 'Client Request timed out' in str(exc_info.value) - @pytest.mark.parametrize('error_cls', list(A2A_REST_ERROR_MAPPING.keys())) + @pytest.mark.parametrize('error_cls', list(A2A_ERROR_MAPPING.keys())) @pytest.mark.asyncio async def test_rest_mapped_errors( self, @@ -129,7 +129,7 @@ async def test_rest_mapped_errors( mock_response = AsyncMock(spec=httpx.Response) mock_response.status_code = 500 - reason = A2A_REST_ERROR_MAPPING[error_cls][2] + reason = A2A_ERROR_MAPPING[error_cls][2] mock_response.json.return_value = { 'error': {