Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/a2a/client/transports/jsonrpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -318,10 +319,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}')
Expand Down
3 changes: 2 additions & 1 deletion src/a2a/server/request_handlers/response_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
87 changes: 65 additions & 22 deletions src/a2a/utils/error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,41 +16,85 @@
Response = Any


from google.protobuf.json_format import ParseError
from google.protobuf.json_format import MessageToDict, ParseError

from a2a.utils.errors import (
A2A_REST_ERROR_MAPPING,
A2A_ERROR_MAPPING,
A2A_ERROR_REASONS,
A2AError,
ErrorMapping,
InternalError,
RestErrorMap,
InvalidParamsError,
)
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 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]] = [_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,
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] = {
'code': code,
'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}


Expand All @@ -61,25 +105,24 @@ 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 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=[_error_info('INVALID_REQUEST')],
)
return _build_error_payload(
code=500,
Expand Down
53 changes: 32 additions & 21 deletions src/a2a/utils/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -94,6 +94,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."""

Expand All @@ -106,22 +112,21 @@ 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',
'InvalidParamsError',
'InvalidRequestError',
'JSONParseError',
Comment thread
sokoliva marked this conversation as resolved.
'MethodNotFoundError',
'PushNotificationNotSupportedError',
'RestErrorMap',
'TaskNotCancelableError',
'TaskNotFoundError',
'UnsupportedOperationError',
Expand All @@ -143,56 +148,62 @@ class VersionNotSupportedError(A2AError):
InvalidRequestError: -32600,
MethodNotFoundError: -32601,
InternalError: -32603,
JSONParseError: -32700,
}


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()
}
84 changes: 84 additions & 0 deletions tests/client/transports/test_jsonrpc_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Loading
Loading