From fdb156bb866ae3ea44c7bce2a0b865d0c33966a1 Mon Sep 17 00:00:00 2001 From: knap Date: Mon, 9 Mar 2026 11:52:40 +0000 Subject: [PATCH 1/2] feat: implement gRPC rich error details for A2A errors, including new error types and client-side parsing. --- src/a2a/client/transports/grpc.py | 46 +++++++++++++++-- .../server/request_handlers/grpc_handler.py | 39 ++++++++++++--- src/a2a/utils/errors.py | 35 +++++++++++++ tests/client/transports/test_grpc_client.py | 49 +++++++++++++++++-- .../request_handlers/test_grpc_handler.py | 46 +++++++++++++++-- 5 files changed, 197 insertions(+), 18 deletions(-) diff --git a/src/a2a/client/transports/grpc.py b/src/a2a/client/transports/grpc.py index 231c1ebb..a33ea06e 100644 --- a/src/a2a/client/transports/grpc.py +++ b/src/a2a/client/transports/grpc.py @@ -1,11 +1,12 @@ import logging -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, Callable, Iterable from functools import wraps -from typing import Any, NoReturn +from typing import Any, NoReturn, cast +from a2a.client.errors import A2AClientError, A2AClientTimeoutError from a2a.client.middleware import ClientCallContext -from a2a.utils.errors import JSON_RPC_ERROR_CODE_MAP +from a2a.utils.errors import JSON_RPC_ERROR_CODE_MAP, A2AError try: @@ -18,8 +19,12 @@ ) from e +from google.rpc import ( # type: ignore[reportMissingModuleSource] + error_details_pb2, + status_pb2, +) + from a2a.client.client import ClientConfig -from a2a.client.errors import A2AClientError, A2AClientTimeoutError from a2a.client.middleware import ClientCallInterceptor from a2a.client.optionals import Channel from a2a.client.transports.base import ClientTransport @@ -44,6 +49,7 @@ TaskPushNotificationConfig, ) from a2a.utils.constants import PROTOCOL_VERSION_CURRENT, VERSION_HEADER +from a2a.utils.errors import A2A_REASON_TO_ERROR from a2a.utils.telemetry import SpanKind, trace_class @@ -54,14 +60,44 @@ } +def _parse_rich_grpc_error( + value: bytes, original_error: grpc.aio.AioRpcError +) -> None: + try: + status = status_pb2.Status.FromString(value) + for detail in status.details: + if detail.Is(error_details_pb2.ErrorInfo.DESCRIPTOR): + error_info = error_details_pb2.ErrorInfo() + detail.Unpack(error_info) + + if error_info.domain == 'a2a-protocol.org': + exception_cls = A2A_REASON_TO_ERROR.get(error_info.reason) + if exception_cls: + raise exception_cls(status.message) from original_error # noqa: TRY301 + except Exception as parse_e: + # Don't swallow A2A errors generated above + if isinstance(parse_e, (A2AError, A2AClientError)): + raise parse_e + logger.warning( + 'Failed to parse grpc-status-details-bin', exc_info=parse_e + ) + + def _map_grpc_error(e: grpc.aio.AioRpcError) -> NoReturn: if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED: raise A2AClientTimeoutError('Client Request timed out') from e + metadata = e.trailing_metadata() + if metadata: + iterable_metadata = cast('Iterable[tuple[str, str | bytes]]', metadata) + for key, value in iterable_metadata: + if key == 'grpc-status-details-bin' and isinstance(value, bytes): + _parse_rich_grpc_error(value, e) + details = e.details() if isinstance(details, str) and ': ' in details: error_type_name, error_message = details.split(': ', 1) - # TODO(#723): Resolving imports by name is temporary until proper error handling structure is added in #723. + # Leaving as fallback for errors that don't use the rich error details. exception_cls = _A2A_ERROR_NAME_TO_CLS.get(error_type_name) if exception_cls: raise exception_cls(error_message) from e diff --git a/src/a2a/server/request_handlers/grpc_handler.py b/src/a2a/server/request_handlers/grpc_handler.py index fd9d042f..57aea73e 100644 --- a/src/a2a/server/request_handlers/grpc_handler.py +++ b/src/a2a/server/request_handlers/grpc_handler.py @@ -3,7 +3,8 @@ import logging from abc import ABC, abstractmethod -from collections.abc import AsyncIterable, Awaitable +from collections.abc import AsyncIterable, Awaitable, Callable +from typing import cast try: @@ -16,9 +17,8 @@ "'pip install a2a-sdk[grpc]'" ) from e -from collections.abc import Callable - -from google.protobuf import empty_pb2, message +from google.protobuf import any_pb2, empty_pb2, message +from google.rpc import error_details_pb2, status_pb2 import a2a.types.a2a_pb2_grpc as a2a_grpc @@ -33,7 +33,7 @@ from a2a.types import a2a_pb2 from a2a.types.a2a_pb2 import AgentCard from a2a.utils import proto_utils -from a2a.utils.errors import A2AError, TaskNotFoundError +from a2a.utils.errors import A2A_ERROR_REASONS, A2AError, TaskNotFoundError from a2a.utils.helpers import maybe_await, validate, validate_async_generator @@ -419,10 +419,37 @@ async def abort_context( ) -> None: """Sets the grpc errors appropriately in the context.""" code = _ERROR_CODE_MAP.get(type(error)) + + status_value = code.value if code else grpc.StatusCode.UNKNOWN.value + status_code = ( + status_value[0] if isinstance(status_value, tuple) else status_value + ) + error_msg = error.message if hasattr(error, 'message') else str(error) + status = status_pb2.Status(code=status_code, message=error_msg) + + if code: + reason = A2A_ERROR_REASONS.get(type(error), 'UNKNOWN_ERROR') + + error_info = error_details_pb2.ErrorInfo( + reason=reason, + domain='a2a-protocol.org', + ) + + detail = any_pb2.Any() + detail.Pack(error_info) + status.details.append(detail) + + context.set_trailing_metadata( + cast( + 'tuple[tuple[str, str | bytes], ...]', + (('grpc-status-details-bin', status.SerializeToString()),), + ) + ) + if code: await context.abort( code, - f'{type(error).__name__}: {error.message}', + status.message, ) else: await context.abort( diff --git a/src/a2a/utils/errors.py b/src/a2a/utils/errors.py index 845bbfca..b4e9fbca 100644 --- a/src/a2a/utils/errors.py +++ b/src/a2a/utils/errors.py @@ -82,11 +82,26 @@ class MethodNotFoundError(A2AError): message = 'Method not found' +class ExtensionSupportRequiredError(A2AError): + """Exception raised when extension support is required but not present.""" + + message = 'Extension support required' + + +class VersionNotSupportedError(A2AError): + """Exception raised when the requested version is not supported.""" + + message = 'Version not supported' + + # For backward compatibility if needed, or just aliases for clean refactor # We remove the Pydantic models here. __all__ = [ + 'A2A_ERROR_REASONS', + 'A2A_REASON_TO_ERROR', 'JSON_RPC_ERROR_CODE_MAP', + 'ExtensionSupportRequiredError', 'InternalError', 'InvalidAgentResponseError', 'InvalidParamsError', @@ -96,6 +111,7 @@ class MethodNotFoundError(A2AError): 'TaskNotCancelableError', 'TaskNotFoundError', 'UnsupportedOperationError', + 'VersionNotSupportedError', ] @@ -112,3 +128,22 @@ class MethodNotFoundError(A2AError): MethodNotFoundError: -32601, InternalError: -32603, } + + +A2A_ERROR_REASONS = { + TaskNotFoundError: 'TASK_NOT_FOUND', + TaskNotCancelableError: 'TASK_NOT_CANCELABLE', + PushNotificationNotSupportedError: 'PUSH_NOTIFICATION_NOT_SUPPORTED', + UnsupportedOperationError: 'UNSUPPORTED_OPERATION', + ContentTypeNotSupportedError: 'CONTENT_TYPE_NOT_SUPPORTED', + InvalidAgentResponseError: 'INVALID_AGENT_RESPONSE', + AuthenticatedExtendedCardNotConfiguredError: 'EXTENDED_AGENT_CARD_NOT_CONFIGURED', + ExtensionSupportRequiredError: 'EXTENSION_SUPPORT_REQUIRED', + VersionNotSupportedError: 'VERSION_NOT_SUPPORTED', + InvalidParamsError: 'INVALID_PARAMS', + InvalidRequestError: 'INVALID_REQUEST', + MethodNotFoundError: 'METHOD_NOT_FOUND', + InternalError: 'INTERNAL_ERROR', +} + +A2A_REASON_TO_ERROR = {reason: cls for cls, reason in A2A_ERROR_REASONS.items()} diff --git a/tests/client/transports/test_grpc_client.py b/tests/client/transports/test_grpc_client.py index a070b18f..4984a58b 100644 --- a/tests/client/transports/test_grpc_client.py +++ b/tests/client/transports/test_grpc_client.py @@ -3,10 +3,14 @@ import grpc import pytest +from google.protobuf import any_pb2 +from google.rpc import error_details_pb2, status_pb2 + from a2a.client.middleware import ClientCallContext from a2a.client.transports.grpc import GrpcTransport from a2a.extensions.common import HTTP_EXTENSION_HEADER from a2a.utils.constants import VERSION_HEADER, PROTOCOL_VERSION_CURRENT +from a2a.utils.errors import A2A_ERROR_REASONS from a2a.types import a2a_pb2 from a2a.types.a2a_pb2 import ( AgentCapabilities, @@ -257,16 +261,15 @@ async def test_send_message_with_timeout_context( @pytest.mark.parametrize('error_cls', list(JSON_RPC_ERROR_CODE_MAP.keys())) @pytest.mark.asyncio -async def test_grpc_mapped_errors( +async def test_grpc_mapped_errors_legacy( grpc_transport: GrpcTransport, mock_grpc_stub: AsyncMock, sample_message_send_params: SendMessageRequest, error_cls, ) -> None: - """Test handling of mapped gRPC error responses.""" + """Test handling of legacy gRPC error responses.""" error_details = f'{error_cls.__name__}: Mapped Error' - # We must trigger it from a standard transport method call, for example `send_message`. mock_grpc_stub.SendMessage.side_effect = grpc.aio.AioRpcError( code=grpc.StatusCode.INTERNAL, initial_metadata=grpc.aio.Metadata(), @@ -278,6 +281,46 @@ async def test_grpc_mapped_errors( await grpc_transport.send_message(sample_message_send_params) +@pytest.mark.parametrize('error_cls', list(JSON_RPC_ERROR_CODE_MAP.keys())) +@pytest.mark.asyncio +async def test_grpc_mapped_errors_rich( + grpc_transport: GrpcTransport, + mock_grpc_stub: AsyncMock, + sample_message_send_params: SendMessageRequest, + error_cls, +) -> None: + """Test handling of rich gRPC error responses with Status metadata.""" + + reason = A2A_ERROR_REASONS.get(error_cls, 'UNKNOWN_ERROR') + + error_info = error_details_pb2.ErrorInfo( + reason=reason, + domain='a2a-protocol.org', + ) + + error_details = f'{error_cls.__name__}: Mapped Error' + status = status_pb2.Status( + code=grpc.StatusCode.INTERNAL.value[0], message=error_details + ) + detail = any_pb2.Any() + detail.Pack(error_info) + status.details.append(detail) + + mock_grpc_stub.SendMessage.side_effect = grpc.aio.AioRpcError( + code=grpc.StatusCode.INTERNAL, + initial_metadata=grpc.aio.Metadata(), + trailing_metadata=grpc.aio.Metadata( + ('grpc-status-details-bin', status.SerializeToString()), + ), + details='A generic error message', + ) + + with pytest.raises(error_cls) as excinfo: + await grpc_transport.send_message(sample_message_send_params) + + assert str(excinfo.value) == error_details + + @pytest.mark.asyncio async def test_send_message_message_response( grpc_transport: GrpcTransport, diff --git a/tests/server/request_handlers/test_grpc_handler.py b/tests/server/request_handlers/test_grpc_handler.py index 88f050aa..954d7e01 100644 --- a/tests/server/request_handlers/test_grpc_handler.py +++ b/tests/server/request_handlers/test_grpc_handler.py @@ -5,6 +5,7 @@ import grpc.aio import pytest +from google.rpc import error_details_pb2, status_pb2 from a2a import types from a2a.extensions.common import HTTP_EXTENSION_HEADER from a2a.server.context import ServerCallContext @@ -99,7 +100,7 @@ async def test_send_message_server_error( await grpc_handler.SendMessage(request_proto, mock_grpc_context) mock_grpc_context.abort.assert_awaited_once_with( - grpc.StatusCode.INVALID_ARGUMENT, 'InvalidParamsError: Bad params' + grpc.StatusCode.INVALID_ARGUMENT, 'Bad params' ) @@ -138,7 +139,7 @@ async def test_get_task_not_found( await grpc_handler.GetTask(request_proto, mock_grpc_context) mock_grpc_context.abort.assert_awaited_once_with( - grpc.StatusCode.NOT_FOUND, 'TaskNotFoundError: Task not found' + grpc.StatusCode.NOT_FOUND, 'Task not found' ) @@ -157,7 +158,7 @@ async def test_cancel_task_server_error( mock_grpc_context.abort.assert_awaited_once_with( grpc.StatusCode.UNIMPLEMENTED, - 'TaskNotCancelableError: Task cannot be canceled', + 'Task cannot be canceled', ) @@ -379,7 +380,44 @@ async def test_abort_context_error_mapping( # noqa: PLR0913 mock_grpc_context.abort.assert_awaited_once() call_args, _ = mock_grpc_context.abort.call_args assert call_args[0] == grpc_status_code - assert error_message_part in call_args[1] + + # We shouldn't rely on the legacy ExceptionName: message string format + # But for backward compatability fallback it shouldn't fail + mock_grpc_context.set_trailing_metadata.assert_called_once() + metadata = mock_grpc_context.set_trailing_metadata.call_args[0][0] + + assert any(key == 'grpc-status-details-bin' for key, _ in metadata) + + +@pytest.mark.asyncio +async def test_abort_context_rich_error_format( + grpc_handler: GrpcHandler, + mock_request_handler: AsyncMock, + mock_grpc_context: AsyncMock, +) -> None: + + error = types.TaskNotFoundError('Could not find the task') + mock_request_handler.on_get_task.side_effect = error + request_proto = a2a_pb2.GetTaskRequest(id='any') + await grpc_handler.GetTask(request_proto, mock_grpc_context) + + mock_grpc_context.set_trailing_metadata.assert_called_once() + metadata = mock_grpc_context.set_trailing_metadata.call_args[0][0] + + bin_values = [v for k, v in metadata if k == 'grpc-status-details-bin'] + assert len(bin_values) == 1 + + status = status_pb2.Status.FromString(bin_values[0]) + assert status.code == grpc.StatusCode.NOT_FOUND.value[0] + assert status.message == 'Could not find the task' + + assert len(status.details) == 1 + + error_info = error_details_pb2.ErrorInfo() + status.details[0].Unpack(error_info) + + assert error_info.reason == 'TASK_NOT_FOUND' + assert error_info.domain == 'a2a-protocol.org' @pytest.mark.asyncio From c536466b89f7c2c4ddfcf416348b98a9cf1cf8e1 Mon Sep 17 00:00:00 2001 From: knap Date: Thu, 12 Mar 2026 11:04:28 +0000 Subject: [PATCH 2/2] fix: clean up legacy grpc error mapping and status handling - Removed unreleased legacy string-split fallback parsing for gRPC errors. - Refactored client and server handlers to use pure grpcio-status helpers for encoding/decoding google.rpc.Status to standard trailing metadata. - Cleaned up A2A_ERROR_REASONS map to strictly contain only the 9 domain errors defined in the specification (removing generic JSON-RPC errors). - Added grpcio-status to pyproject.toml dependencies. --- .jscpd.json | 10 +++- pyproject.toml | 2 +- src/a2a/client/transports/grpc.py | 52 +++++-------------- .../server/request_handlers/grpc_handler.py | 46 ++++++++-------- .../request_handlers/response_helpers.py | 16 +----- src/a2a/utils/errors.py | 4 -- tests/client/transports/test_grpc_client.py | 27 +--------- uv.lock | 28 ++++++++-- 8 files changed, 75 insertions(+), 110 deletions(-) diff --git a/.jscpd.json b/.jscpd.json index 5a6fcad7..ed59a649 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -1,5 +1,13 @@ { - "ignore": ["**/.github/**", "**/.git/**", "**/tests/**", "**/src/a2a/grpc/**", "**/.nox/**", "**/.venv/**"], + "ignore": [ + "**/.github/**", + "**/.git/**", + "**/tests/**", + "**/src/a2a/grpc/**", + "**/src/a2a/compat/**", + "**/.nox/**", + "**/.venv/**" + ], "threshold": 3, "reporters": ["html", "markdown"] } diff --git a/pyproject.toml b/pyproject.toml index 0814a70e..dd636b10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ [project.optional-dependencies] http-server = ["fastapi>=0.115.2", "sse-starlette", "starlette"] encryption = ["cryptography>=43.0.0"] -grpc = ["grpcio>=1.60", "grpcio-tools>=1.60", "grpcio_reflection>=1.7.0"] +grpc = ["grpcio>=1.60", "grpcio-tools>=1.60", "grpcio-status>=1.60", "grpcio_reflection>=1.7.0"] telemetry = ["opentelemetry-api>=1.33.0", "opentelemetry-sdk>=1.33.0"] postgresql = ["sqlalchemy[asyncio,postgresql-asyncpg]>=2.0.0"] mysql = ["sqlalchemy[asyncio,aiomysql]>=2.0.0"] diff --git a/src/a2a/client/transports/grpc.py b/src/a2a/client/transports/grpc.py index a33ea06e..1bb2151b 100644 --- a/src/a2a/client/transports/grpc.py +++ b/src/a2a/client/transports/grpc.py @@ -1,19 +1,20 @@ import logging -from collections.abc import AsyncGenerator, Callable, Iterable +from collections.abc import AsyncGenerator, Callable from functools import wraps from typing import Any, NoReturn, cast from a2a.client.errors import A2AClientError, A2AClientTimeoutError from a2a.client.middleware import ClientCallContext -from a2a.utils.errors import JSON_RPC_ERROR_CODE_MAP, A2AError try: import grpc # type: ignore[reportMissingModuleSource] + + from grpc_status import rpc_status except ImportError as e: raise ImportError( - 'A2AGrpcClient requires grpcio and grpcio-tools to be installed. ' + 'A2AGrpcClient requires grpcio, grpcio-tools, and grpcio-status to be installed. ' 'Install with: ' "'pip install a2a-sdk[grpc]'" ) from e @@ -21,7 +22,6 @@ from google.rpc import ( # type: ignore[reportMissingModuleSource] error_details_pb2, - status_pb2, ) from a2a.client.client import ClientConfig @@ -55,16 +55,16 @@ logger = logging.getLogger(__name__) -_A2A_ERROR_NAME_TO_CLS = { - error_type.__name__: error_type for error_type in JSON_RPC_ERROR_CODE_MAP -} +def _map_grpc_error(e: grpc.aio.AioRpcError) -> NoReturn: + + if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED: + raise A2AClientTimeoutError('Client Request timed out') from e + + # Use grpc_status to cleanly extract the rich Status from the call + status = rpc_status.from_call(cast('grpc.Call', e)) -def _parse_rich_grpc_error( - value: bytes, original_error: grpc.aio.AioRpcError -) -> None: - try: - status = status_pb2.Status.FromString(value) + if status is not None: for detail in status.details: if detail.Is(error_details_pb2.ErrorInfo.DESCRIPTOR): error_info = error_details_pb2.ErrorInfo() @@ -73,34 +73,8 @@ def _parse_rich_grpc_error( if error_info.domain == 'a2a-protocol.org': exception_cls = A2A_REASON_TO_ERROR.get(error_info.reason) if exception_cls: - raise exception_cls(status.message) from original_error # noqa: TRY301 - except Exception as parse_e: - # Don't swallow A2A errors generated above - if isinstance(parse_e, (A2AError, A2AClientError)): - raise parse_e - logger.warning( - 'Failed to parse grpc-status-details-bin', exc_info=parse_e - ) - - -def _map_grpc_error(e: grpc.aio.AioRpcError) -> NoReturn: - if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED: - raise A2AClientTimeoutError('Client Request timed out') from e + raise exception_cls(status.message) from e - metadata = e.trailing_metadata() - if metadata: - iterable_metadata = cast('Iterable[tuple[str, str | bytes]]', metadata) - for key, value in iterable_metadata: - if key == 'grpc-status-details-bin' and isinstance(value, bytes): - _parse_rich_grpc_error(value, e) - - details = e.details() - if isinstance(details, str) and ': ' in details: - error_type_name, error_message = details.split(': ', 1) - # Leaving as fallback for errors that don't use the rich error details. - exception_cls = _A2A_ERROR_NAME_TO_CLS.get(error_type_name) - if exception_cls: - raise exception_cls(error_message) from e raise A2AClientError(f'gRPC Error {e.code().name}: {e.details()}') from e diff --git a/src/a2a/server/request_handlers/grpc_handler.py b/src/a2a/server/request_handlers/grpc_handler.py index 57aea73e..35ad095a 100644 --- a/src/a2a/server/request_handlers/grpc_handler.py +++ b/src/a2a/server/request_handlers/grpc_handler.py @@ -4,15 +4,16 @@ from abc import ABC, abstractmethod from collections.abc import AsyncIterable, Awaitable, Callable -from typing import cast try: import grpc # type: ignore[reportMissingModuleSource] import grpc.aio # type: ignore[reportMissingModuleSource] + + from grpc_status import rpc_status except ImportError as e: raise ImportError( - 'GrpcHandler requires grpcio and grpcio-tools to be installed. ' + 'GrpcHandler requires grpcio, grpcio-tools, and grpcio-status to be installed. ' 'Install with: ' "'pip install a2a-sdk[grpc]'" ) from e @@ -420,37 +421,40 @@ async def abort_context( """Sets the grpc errors appropriately in the context.""" code = _ERROR_CODE_MAP.get(type(error)) - status_value = code.value if code else grpc.StatusCode.UNKNOWN.value - status_code = ( - status_value[0] if isinstance(status_value, tuple) else status_value - ) - error_msg = error.message if hasattr(error, 'message') else str(error) - status = status_pb2.Status(code=status_code, message=error_msg) - if code: reason = A2A_ERROR_REASONS.get(type(error), 'UNKNOWN_ERROR') - error_info = error_details_pb2.ErrorInfo( reason=reason, domain='a2a-protocol.org', ) + status_code = ( + code.value[0] if code else grpc.StatusCode.UNKNOWN.value[0] + ) + error_msg = ( + error.message if hasattr(error, 'message') else str(error) + ) + + # Create standard Status and pack the ErrorInfo + status = status_pb2.Status(code=status_code, message=error_msg) detail = any_pb2.Any() detail.Pack(error_info) status.details.append(detail) - context.set_trailing_metadata( - cast( - 'tuple[tuple[str, str | bytes], ...]', - (('grpc-status-details-bin', status.SerializeToString()),), - ) - ) + # Use grpc_status to safely generate standard trailing metadata + rich_status = rpc_status.to_status(status) - if code: - await context.abort( - code, - status.message, - ) + new_metadata: list[tuple[str, str | bytes]] = [] + trailing = context.trailing_metadata() + if trailing: + for k, v in trailing: + new_metadata.append((str(k), v)) + + for k, v in rich_status.trailing_metadata: + new_metadata.append((str(k), v)) + + context.set_trailing_metadata(tuple(new_metadata)) + await context.abort(rich_status.code, rich_status.details) else: await context.abort( grpc.StatusCode.UNKNOWN, diff --git a/src/a2a/server/request_handlers/response_helpers.py b/src/a2a/server/request_handlers/response_helpers.py index 5f38a0a6..f7bffd60 100644 --- a/src/a2a/server/request_handlers/response_helpers.py +++ b/src/a2a/server/request_handlers/response_helpers.py @@ -27,6 +27,7 @@ SendMessageResponse as SendMessageResponseProto, ) from a2a.utils.errors import ( + JSON_RPC_ERROR_CODE_MAP, A2AError, AuthenticatedExtendedCardNotConfiguredError, ContentTypeNotSupportedError, @@ -56,19 +57,6 @@ InternalError: JSONRPCInternalError, } -ERROR_CODE_MAP: dict[type[A2AError], int] = { - TaskNotFoundError: -32001, - TaskNotCancelableError: -32002, - PushNotificationNotSupportedError: -32003, - UnsupportedOperationError: -32004, - ContentTypeNotSupportedError: -32005, - InvalidAgentResponseError: -32006, - AuthenticatedExtendedCardNotConfiguredError: -32007, - InvalidParamsError: -32602, - InvalidRequestError: -32600, - MethodNotFoundError: -32601, -} - # Tuple of all A2AError types for isinstance checks _A2A_ERROR_TYPES: tuple[type, ...] = (A2AError,) @@ -136,7 +124,7 @@ def build_error_response( elif isinstance(error, A2AError): error_type = type(error) model_class = EXCEPTION_MAP.get(error_type, JSONRPCInternalError) - code = ERROR_CODE_MAP.get(error_type, -32603) + code = JSON_RPC_ERROR_CODE_MAP.get(error_type, -32603) jsonrpc_error = model_class( code=code, message=str(error), diff --git a/src/a2a/utils/errors.py b/src/a2a/utils/errors.py index b4e9fbca..9353805e 100644 --- a/src/a2a/utils/errors.py +++ b/src/a2a/utils/errors.py @@ -140,10 +140,6 @@ class VersionNotSupportedError(A2AError): AuthenticatedExtendedCardNotConfiguredError: 'EXTENDED_AGENT_CARD_NOT_CONFIGURED', ExtensionSupportRequiredError: 'EXTENSION_SUPPORT_REQUIRED', VersionNotSupportedError: 'VERSION_NOT_SUPPORTED', - InvalidParamsError: 'INVALID_PARAMS', - InvalidRequestError: 'INVALID_REQUEST', - MethodNotFoundError: 'METHOD_NOT_FOUND', - InternalError: 'INTERNAL_ERROR', } A2A_REASON_TO_ERROR = {reason: cls for cls, reason in A2A_ERROR_REASONS.items()} diff --git a/tests/client/transports/test_grpc_client.py b/tests/client/transports/test_grpc_client.py index 4984a58b..33c0865b 100644 --- a/tests/client/transports/test_grpc_client.py +++ b/tests/client/transports/test_grpc_client.py @@ -36,7 +36,6 @@ TaskStatusUpdateEvent, ) from a2a.utils import get_text_parts -from a2a.utils.errors import JSON_RPC_ERROR_CODE_MAP @pytest.fixture @@ -259,29 +258,7 @@ async def test_send_message_with_timeout_context( assert kwargs['timeout'] == 12.5 -@pytest.mark.parametrize('error_cls', list(JSON_RPC_ERROR_CODE_MAP.keys())) -@pytest.mark.asyncio -async def test_grpc_mapped_errors_legacy( - grpc_transport: GrpcTransport, - mock_grpc_stub: AsyncMock, - sample_message_send_params: SendMessageRequest, - error_cls, -) -> None: - """Test handling of legacy gRPC error responses.""" - error_details = f'{error_cls.__name__}: Mapped Error' - - mock_grpc_stub.SendMessage.side_effect = grpc.aio.AioRpcError( - code=grpc.StatusCode.INTERNAL, - initial_metadata=grpc.aio.Metadata(), - trailing_metadata=grpc.aio.Metadata(), - details=error_details, - ) - - with pytest.raises(error_cls): - await grpc_transport.send_message(sample_message_send_params) - - -@pytest.mark.parametrize('error_cls', list(JSON_RPC_ERROR_CODE_MAP.keys())) +@pytest.mark.parametrize('error_cls', list(A2A_ERROR_REASONS.keys())) @pytest.mark.asyncio async def test_grpc_mapped_errors_rich( grpc_transport: GrpcTransport, @@ -312,7 +289,7 @@ async def test_grpc_mapped_errors_rich( trailing_metadata=grpc.aio.Metadata( ('grpc-status-details-bin', status.SerializeToString()), ), - details='A generic error message', + details=error_details, ) with pytest.raises(error_cls) as excinfo: diff --git a/uv.lock b/uv.lock index 8c7dfb31..4edf51c3 100644 --- a/uv.lock +++ b/uv.lock @@ -21,15 +21,13 @@ dependencies = [ ] [package.optional-dependencies] -db-cli = [ - { name = "alembic" }, -] all = [ { name = "alembic" }, { name = "cryptography" }, { name = "fastapi" }, { name = "grpcio" }, { name = "grpcio-reflection" }, + { name = "grpcio-status" }, { name = "grpcio-tools" }, { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, @@ -38,12 +36,16 @@ all = [ { name = "sse-starlette" }, { name = "starlette" }, ] +db-cli = [ + { name = "alembic" }, +] encryption = [ { name = "cryptography" }, ] grpc = [ { name = "grpcio" }, { name = "grpcio-reflection" }, + { name = "grpcio-status" }, { name = "grpcio-tools" }, ] http-server = [ @@ -97,8 +99,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "alembic", marker = "extra == 'db-cli'", specifier = ">=1.14.0" }, { name = "alembic", marker = "extra == 'all'", specifier = ">=1.14.0" }, + { name = "alembic", marker = "extra == 'db-cli'", specifier = ">=1.14.0" }, { name = "cryptography", marker = "extra == 'all'", specifier = ">=43.0.0" }, { name = "cryptography", marker = "extra == 'encryption'", specifier = ">=43.0.0" }, { name = "fastapi", marker = "extra == 'all'", specifier = ">=0.115.2" }, @@ -109,6 +111,8 @@ requires-dist = [ { name = "grpcio", marker = "extra == 'grpc'", specifier = ">=1.60" }, { name = "grpcio-reflection", marker = "extra == 'all'", specifier = ">=1.7.0" }, { name = "grpcio-reflection", marker = "extra == 'grpc'", specifier = ">=1.7.0" }, + { name = "grpcio-status", marker = "extra == 'all'", specifier = ">=1.60" }, + { name = "grpcio-status", marker = "extra == 'grpc'", specifier = ">=1.60" }, { name = "grpcio-tools", marker = "extra == 'all'", specifier = ">=1.60" }, { name = "grpcio-tools", marker = "extra == 'grpc'", specifier = ">=1.60" }, { name = "httpx", specifier = ">=0.28.1" }, @@ -136,7 +140,7 @@ requires-dist = [ { name = "starlette", marker = "extra == 'all'" }, { name = "starlette", marker = "extra == 'http-server'" }, ] -provides-extras = ["db-cli", "all", "encryption", "grpc", "http-server", "mysql", "postgresql", "signing", "sql", "sqlite", "telemetry"] +provides-extras = ["all", "db-cli", "encryption", "grpc", "http-server", "mysql", "postgresql", "signing", "sql", "sqlite", "telemetry"] [package.metadata.requires-dev] dev = [ @@ -960,6 +964,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/6d/4d095d27ccd049865ecdafc467754e9e47ad0f677a30dda969c3590f6582/grpcio_reflection-1.78.0-py3-none-any.whl", hash = "sha256:06fcfde9e6888cdd12e9dd1cf6dc7c440c2e9acf420f696ccbe008672ed05b60", size = 22800, upload-time = "2026-02-06T10:01:33.822Z" }, ] +[[package]] +name = "grpcio-status" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/cd/89ce482a931b543b92cdd9b2888805518c4620e0094409acb8c81dd4610a/grpcio_status-1.78.0.tar.gz", hash = "sha256:a34cfd28101bfea84b5aa0f936b4b423019e9213882907166af6b3bddc59e189", size = 13808, upload-time = "2026-02-06T10:01:48.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/8a/1241ec22c41028bddd4a052ae9369267b4475265ad0ce7140974548dc3fa/grpcio_status-1.78.0-py3-none-any.whl", hash = "sha256:b492b693d4bf27b47a6c32590701724f1d3b9444b36491878fb71f6208857f34", size = 14523, upload-time = "2026-02-06T10:01:32.584Z" }, +] + [[package]] name = "grpcio-tools" version = "1.78.0"