Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 12 additions & 3 deletions src/a2a/client/transports/jsonrpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,33 @@
from google.protobuf import json_format
from jsonrpc.jsonrpc2 import JSONRPC20Request, JSONRPC20Response

from a2a.client.client import ClientCallContext
from a2a.client.errors import A2AClientError
from a2a.client.transports.base import ClientTransport
from a2a.client.transports.http_helpers import (
get_http_args,
send_http_request,
send_http_stream_request,
)
from a2a.types.a2a_pb2 import (
AgentCard,
CancelTaskRequest,
DeleteTaskPushNotificationConfigRequest,
GetExtendedAgentCardRequest,
GetTaskPushNotificationConfigRequest,
GetTaskRequest,
ListTaskPushNotificationConfigsRequest,
ListTaskPushNotificationConfigsResponse,
ListTasksRequest,
ListTasksResponse,
SendMessageRequest,
SendMessageResponse,
StreamResponse,
SubscribeToTaskRequest,
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

Check notice on line 38 in src/a2a/client/transports/jsonrpc.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Copy/pasted code

see src/a2a/client/transports/rest.py (11-37)
from a2a.utils.telemetry import SpanKind, trace_class


Expand Down Expand Up @@ -318,10 +318,19 @@
"""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 @@ -42,6 +42,7 @@
TaskNotFoundError,
UnsupportedOperationError,
VersionNotSupportedError,
build_error_details,
)


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
35 changes: 18 additions & 17 deletions src/a2a/utils/error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand All @@ -33,24 +36,16 @@ 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 @@ -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,
Expand Down
60 changes: 59 additions & 1 deletion src/a2a/utils/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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."""

Expand All @@ -119,6 +131,7 @@ class VersionNotSupportedError(A2AError):
'InvalidAgentResponseError',
'InvalidParamsError',
'InvalidRequestError',
'JSONParseError',
Comment thread
sokoliva marked this conversation as resolved.
'MethodNotFoundError',
'PushNotificationNotSupportedError',
'RestErrorMap',
Expand All @@ -143,6 +156,7 @@ class VersionNotSupportedError(A2AError):
InvalidRequestError: -32600,
MethodNotFoundError: -32601,
InternalError: -32603,
JSONParseError: -32700,
}


Expand Down Expand Up @@ -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
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'
72 changes: 72 additions & 0 deletions tests/server/request_handlers/test_response_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading