Skip to content

Commit 2438832

Browse files
committed
Refactor REST error handling to use google.rpc.Status (AIP-193)
1 parent a910cbc commit 2438832

7 files changed

Lines changed: 229 additions & 109 deletions

File tree

src/a2a/client/transports/rest.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,12 @@
3434
Task,
3535
TaskPushNotificationConfig,
3636
)
37-
from a2a.utils.errors import JSON_RPC_ERROR_CODE_MAP, MethodNotFoundError
37+
from a2a.utils.errors import A2A_REASON_TO_ERROR, MethodNotFoundError
3838
from a2a.utils.telemetry import SpanKind, trace_class
3939

4040

4141
logger = logging.getLogger(__name__)
4242

43-
_A2A_ERROR_NAME_TO_CLS = {
44-
error_type.__name__: error_type for error_type in JSON_RPC_ERROR_CODE_MAP
45-
}
46-
4743

4844
@trace_class(kind=SpanKind.CLIENT)
4945
class RestTransport(ClientTransport):
@@ -297,15 +293,36 @@ def _get_path(self, base_path: str, tenant: str) -> str:
297293
def _handle_http_error(self, e: httpx.HTTPStatusError) -> NoReturn:
298294
"""Handles HTTP status errors and raises the appropriate A2AError."""
299295
try:
300-
error_data = e.response.json()
301-
error_type = error_data.get('type')
302-
message = error_data.get('message', str(e))
296+
error_payload = e.response.json()
297+
error_data = error_payload.get('error', {})
303298

304-
if isinstance(error_type, str):
305-
# TODO(#723): Resolving imports by name is temporary until proper error handling structure is added in #723.
306-
exception_cls = _A2A_ERROR_NAME_TO_CLS.get(error_type)
299+
message = error_data.get('message', str(e))
300+
details = error_data.get('details', [])
301+
if not isinstance(details, list):
302+
details = []
303+
304+
# The `details` array can contain multiple different error objects.
305+
# We extract the first `ErrorInfo` object because it contains the
306+
# specific `reason` code needed to map this back to a Python A2AError.
307+
error_info = {}
308+
for d in details:
309+
if (
310+
isinstance(d, dict)
311+
and d.get('@type')
312+
== 'type.googleapis.com/google.rpc.ErrorInfo'
313+
):
314+
error_info = d
315+
break
316+
reason = error_info.get('reason')
317+
metadata = error_info.get('metadata') or {}
318+
319+
if isinstance(reason, str):
320+
exception_cls = A2A_REASON_TO_ERROR.get(reason)
307321
if exception_cls:
308-
raise exception_cls(message) from e
322+
exc = exception_cls(message)
323+
if metadata:
324+
exc.data = metadata
325+
raise exc from e
309326
except (json.JSONDecodeError, ValueError):
310327
pass
311328

src/a2a/server/apps/rest/fastapi_app.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,22 @@
77
if TYPE_CHECKING:
88
from fastapi import APIRouter, FastAPI, Request, Response
99
from fastapi.responses import JSONResponse
10+
from starlette.exceptions import HTTPException as StarletteHTTPException
1011

1112
_package_fastapi_installed = True
1213
else:
1314
try:
1415
from fastapi import APIRouter, FastAPI, Request, Response
1516
from fastapi.responses import JSONResponse
17+
from starlette.exceptions import HTTPException as StarletteHTTPException
1618

1719
_package_fastapi_installed = True
1820
except ImportError:
1921
APIRouter = Any
2022
FastAPI = Any
2123
Request = Any
2224
Response = Any
25+
StarletteHTTPException = Any
2326

2427
_package_fastapi_installed = False
2528

@@ -36,6 +39,23 @@
3639
logger = logging.getLogger(__name__)
3740

3841

42+
_HTTP_TO_GRPC_STATUS_MAP = {
43+
400: 'INVALID_ARGUMENT',
44+
401: 'UNAUTHENTICATED',
45+
403: 'PERMISSION_DENIED',
46+
404: 'NOT_FOUND',
47+
405: 'UNIMPLEMENTED',
48+
409: 'ALREADY_EXISTS',
49+
415: 'INVALID_ARGUMENT',
50+
422: 'INVALID_ARGUMENT',
51+
500: 'INTERNAL',
52+
501: 'UNIMPLEMENTED',
53+
502: 'INTERNAL',
54+
503: 'UNAVAILABLE',
55+
504: 'DEADLINE_EXCEEDED',
56+
}
57+
58+
3959
class A2ARESTFastAPIApplication:
4060
"""A FastAPI application implementing the A2A protocol server REST endpoints.
4161
@@ -121,6 +141,34 @@ def build(
121141
A configured FastAPI application instance.
122142
"""
123143
app = FastAPI(**kwargs)
144+
145+
@app.exception_handler(StarletteHTTPException)
146+
async def http_exception_handler(
147+
request: Request, exc: StarletteHTTPException
148+
) -> Response:
149+
"""Catches framework-level HTTP exceptions.
150+
151+
For example, 404 Not Found for bad routes, 422 Unprocessable Entity
152+
for schema validation, and formats them into the A2A standard
153+
google.rpc.Status JSON format (AIP-193).
154+
"""
155+
grpc_status = _HTTP_TO_GRPC_STATUS_MAP.get(
156+
exc.status_code, 'UNKNOWN'
157+
)
158+
return JSONResponse(
159+
status_code=exc.status_code,
160+
content={
161+
'error': {
162+
'code': exc.status_code,
163+
'status': grpc_status,
164+
'message': str(exc.detail)
165+
if hasattr(exc, 'detail')
166+
else 'HTTP Exception',
167+
}
168+
},
169+
media_type='application/json',
170+
)
171+
124172
if self.enable_v0_3_compat and self._v03_adapter:
125173
v03_adapter = self._v03_adapter
126174
v03_router = APIRouter()

src/a2a/utils/error_handlers.py

Lines changed: 45 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33

44
from collections.abc import Awaitable, Callable, Coroutine
5-
from typing import TYPE_CHECKING, Any, cast
5+
from typing import TYPE_CHECKING, Any
66

77

88
if TYPE_CHECKING:
@@ -17,71 +17,15 @@
1717

1818
from google.protobuf.json_format import ParseError
1919

20-
from a2a.server.jsonrpc_models import (
21-
InternalError as JSONRPCInternalError,
22-
)
23-
from a2a.server.jsonrpc_models import (
24-
JSONParseError,
25-
JSONRPCError,
26-
)
2720
from a2a.utils.errors import (
21+
A2A_REST_ERROR_MAPPING,
2822
A2AError,
29-
ContentTypeNotSupportedError,
30-
ExtendedAgentCardNotConfiguredError,
31-
ExtensionSupportRequiredError,
3223
InternalError,
33-
InvalidAgentResponseError,
34-
InvalidParamsError,
35-
InvalidRequestError,
36-
MethodNotFoundError,
37-
PushNotificationNotSupportedError,
38-
TaskNotCancelableError,
39-
TaskNotFoundError,
40-
UnsupportedOperationError,
41-
VersionNotSupportedError,
4224
)
4325

4426

4527
logger = logging.getLogger(__name__)
4628

47-
_A2AErrorType = (
48-
type[JSONRPCError]
49-
| type[JSONParseError]
50-
| type[InvalidRequestError]
51-
| type[MethodNotFoundError]
52-
| type[InvalidParamsError]
53-
| type[InternalError]
54-
| type[JSONRPCInternalError]
55-
| type[TaskNotFoundError]
56-
| type[TaskNotCancelableError]
57-
| type[PushNotificationNotSupportedError]
58-
| type[UnsupportedOperationError]
59-
| type[ContentTypeNotSupportedError]
60-
| type[InvalidAgentResponseError]
61-
| type[ExtendedAgentCardNotConfiguredError]
62-
| type[ExtensionSupportRequiredError]
63-
| type[VersionNotSupportedError]
64-
)
65-
66-
A2AErrorToHttpStatus: dict[_A2AErrorType, int] = {
67-
JSONRPCError: 500,
68-
JSONParseError: 400,
69-
InvalidRequestError: 400,
70-
MethodNotFoundError: 404,
71-
InvalidParamsError: 422,
72-
InternalError: 500,
73-
JSONRPCInternalError: 500,
74-
TaskNotFoundError: 404,
75-
TaskNotCancelableError: 409,
76-
PushNotificationNotSupportedError: 501,
77-
UnsupportedOperationError: 501,
78-
ContentTypeNotSupportedError: 415,
79-
InvalidAgentResponseError: 502,
80-
ExtendedAgentCardNotConfiguredError: 400,
81-
ExtensionSupportRequiredError: 400,
82-
VersionNotSupportedError: 400,
83-
}
84-
8529

8630
def rest_error_handler(
8731
func: Callable[..., Awaitable[Response]],
@@ -93,8 +37,8 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response:
9337
try:
9438
return await func(*args, **kwargs)
9539
except A2AError as error:
96-
http_code = A2AErrorToHttpStatus.get(
97-
cast('_A2AErrorType', type(error)), 500
40+
http_code, grpc_status, reason = A2A_REST_ERROR_MAPPING.get(
41+
type(error), (500, 'INTERNAL', 'INTERNAL_ERROR')
9842
)
9943

10044
log_level = (
@@ -107,32 +51,64 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response:
10751
"Request error: Code=%s, Message='%s'%s",
10852
getattr(error, 'code', 'N/A'),
10953
getattr(error, 'message', str(error)),
110-
', Data=' + str(getattr(error, 'data', ''))
111-
if getattr(error, 'data', None)
112-
else '',
54+
f', Data={error.data}' if hasattr(error, 'data') else '',
11355
)
114-
# TODO(#722): Standardize error response format.
56+
57+
# SECURITY WARNING: Data attached to A2AError.data is serialized unaltered and exposed publicly to the client in the REST API response.
58+
metadata = getattr(error, 'data', None) or {}
59+
11560
return JSONResponse(
11661
content={
117-
'message': getattr(error, 'message', str(error)),
118-
'type': type(error).__name__,
62+
'error': {
63+
'code': http_code,
64+
'status': grpc_status,
65+
'message': getattr(error, 'message', str(error)),
66+
'details': [
67+
{
68+
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
69+
'reason': reason,
70+
'domain': 'a2a-protocol.org',
71+
'metadata': metadata,
72+
}
73+
],
74+
}
11975
},
12076
status_code=http_code,
77+
media_type='application/json',
12178
)
12279
except ParseError as error:
12380
logger.warning('Parse error: %s', str(error))
12481
return JSONResponse(
12582
content={
126-
'message': str(error),
127-
'type': 'ParseError',
83+
'error': {
84+
'code': 400,
85+
'status': 'INVALID_ARGUMENT',
86+
'message': str(error),
87+
'details': [
88+
{
89+
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
90+
'reason': 'INVALID_REQUEST',
91+
'domain': 'a2a-protocol.org',
92+
'metadata': {},
93+
}
94+
],
95+
}
12896
},
12997
status_code=400,
98+
media_type='application/json',
13099
)
131100
except Exception:
132101
logger.exception('Unknown error occurred')
133102
return JSONResponse(
134-
content={'message': 'unknown exception', 'type': 'Exception'},
103+
content={
104+
'error': {
105+
'code': 500,
106+
'status': 'INTERNAL',
107+
'message': 'unknown exception',
108+
}
109+
},
135110
status_code=500,
111+
media_type='application/json',
136112
)
137113

138114
return wrapper

src/a2a/utils/errors.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class A2AError(Exception):
99
"""Base exception for A2A errors."""
1010

1111
message: str = 'A2A Error'
12+
data: dict | None = None
1213

1314
def __init__(self, message: str | None = None):
1415
if message:
@@ -100,6 +101,7 @@ class VersionNotSupportedError(A2AError):
100101
__all__ = [
101102
'A2A_ERROR_REASONS',
102103
'A2A_REASON_TO_ERROR',
104+
'A2A_REST_ERROR_MAPPING',
103105
'JSON_RPC_ERROR_CODE_MAP',
104106
'ExtensionSupportRequiredError',
105107
'InternalError',
@@ -132,16 +134,43 @@ class VersionNotSupportedError(A2AError):
132134
}
133135

134136

137+
A2A_REST_ERROR_MAPPING: dict[type[A2AError], tuple[int, str, str]] = {
138+
TaskNotFoundError: (404, 'NOT_FOUND', 'TASK_NOT_FOUND'),
139+
TaskNotCancelableError: (409, 'FAILED_PRECONDITION', 'TASK_NOT_CANCELABLE'),
140+
PushNotificationNotSupportedError: (
141+
400,
142+
'UNIMPLEMENTED',
143+
'PUSH_NOTIFICATION_NOT_SUPPORTED',
144+
),
145+
UnsupportedOperationError: (400, 'UNIMPLEMENTED', 'UNSUPPORTED_OPERATION'),
146+
ContentTypeNotSupportedError: (
147+
415,
148+
'INVALID_ARGUMENT',
149+
'CONTENT_TYPE_NOT_SUPPORTED',
150+
),
151+
InvalidAgentResponseError: (502, 'INTERNAL', 'INVALID_AGENT_RESPONSE'),
152+
ExtendedAgentCardNotConfiguredError: (
153+
400,
154+
'FAILED_PRECONDITION',
155+
'EXTENDED_AGENT_CARD_NOT_CONFIGURED',
156+
),
157+
ExtensionSupportRequiredError: (
158+
400,
159+
'FAILED_PRECONDITION',
160+
'EXTENSION_SUPPORT_REQUIRED',
161+
),
162+
VersionNotSupportedError: (400, 'UNIMPLEMENTED', 'VERSION_NOT_SUPPORTED'),
163+
InvalidParamsError: (400, 'INVALID_ARGUMENT', 'INVALID_PARAMS'),
164+
InvalidRequestError: (400, 'INVALID_ARGUMENT', 'INVALID_REQUEST'),
165+
MethodNotFoundError: (404, 'NOT_FOUND', 'METHOD_NOT_FOUND'),
166+
InternalError: (500, 'INTERNAL', 'INTERNAL_ERROR'),
167+
}
168+
169+
135170
A2A_ERROR_REASONS = {
136-
TaskNotFoundError: 'TASK_NOT_FOUND',
137-
TaskNotCancelableError: 'TASK_NOT_CANCELABLE',
138-
PushNotificationNotSupportedError: 'PUSH_NOTIFICATION_NOT_SUPPORTED',
139-
UnsupportedOperationError: 'UNSUPPORTED_OPERATION',
140-
ContentTypeNotSupportedError: 'CONTENT_TYPE_NOT_SUPPORTED',
141-
InvalidAgentResponseError: 'INVALID_AGENT_RESPONSE',
142-
ExtendedAgentCardNotConfiguredError: 'EXTENDED_AGENT_CARD_NOT_CONFIGURED',
143-
ExtensionSupportRequiredError: 'EXTENSION_SUPPORT_REQUIRED',
144-
VersionNotSupportedError: 'VERSION_NOT_SUPPORTED',
171+
cls: reason for cls, (_, _, reason) in A2A_REST_ERROR_MAPPING.items()
145172
}
146173

147-
A2A_REASON_TO_ERROR = {reason: cls for cls, reason in A2A_ERROR_REASONS.items()}
174+
A2A_REASON_TO_ERROR = {
175+
reason: cls for cls, (_, _, reason) in A2A_REST_ERROR_MAPPING.items()
176+
}

0 commit comments

Comments
 (0)