Skip to content

Commit 9d37eb3

Browse files
committed
feat(rest): implement RFC 9457 problem details for errors
- Updates REST error handlers to return RFC 9457 compliant `application/problem+json` responses. - Adds `A2AErrorToTypeURI` and `A2AErrorToTitle` mappings for A2A protocol errors. - Adds a global StarletteHTTPException handler in the FastAPI app. - Wraps the `/well-known/agent.json` endpoint with `rest_error_handler`. - Updates unit tests to verify the new Problem Details response format. Refs #722
1 parent 627ae0b commit 9d37eb3

4 files changed

Lines changed: 205 additions & 40 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,15 @@
2424
_package_fastapi_installed = False
2525

2626

27+
from starlette.exceptions import HTTPException as StarletteHTTPException
28+
2729
from a2a.server.apps.jsonrpc.jsonrpc_app import CallContextBuilder
2830
from a2a.server.apps.rest.rest_adapter import RESTAdapter
2931
from a2a.server.context import ServerCallContext
3032
from a2a.server.request_handlers.request_handler import RequestHandler
3133
from a2a.types.a2a_pb2 import AgentCard
3234
from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH
35+
from a2a.utils.error_handlers import rest_error_handler
3336

3437

3538
logger = logging.getLogger(__name__)
@@ -112,10 +115,28 @@ def build(
112115
f'{rpc_url}{route[0]}', callback, methods=[route[1]]
113116
)
114117

118+
# Catch exceptions thrown by card modifiers.
115119
@router.get(f'{rpc_url}{agent_card_url}')
120+
@rest_error_handler
116121
async def get_agent_card(request: Request) -> Response:
117122
card = await self._adapter.handle_get_agent_card(request)
118123
return JSONResponse(card)
119124

120125
app.include_router(router)
126+
127+
@app.exception_handler(StarletteHTTPException)
128+
async def http_exception_handler(
129+
request: Request, exc: StarletteHTTPException
130+
) -> JSONResponse:
131+
return JSONResponse(
132+
status_code=exc.status_code,
133+
content={
134+
'type': 'about:blank',
135+
'title': 'HTTP Error',
136+
'status': exc.status_code,
137+
'detail': exc.detail,
138+
},
139+
media_type='application/problem+json',
140+
)
141+
121142
return app

src/a2a/utils/error_handlers.py

Lines changed: 79 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -62,19 +62,86 @@
6262
JSONParseError: 400,
6363
InvalidRequestError: 400,
6464
MethodNotFoundError: 404,
65-
InvalidParamsError: 422,
65+
InvalidParamsError: 400,
6666
InternalError: 500,
6767
JSONRPCInternalError: 500,
6868
TaskNotFoundError: 404,
6969
TaskNotCancelableError: 409,
70-
PushNotificationNotSupportedError: 501,
71-
UnsupportedOperationError: 501,
70+
PushNotificationNotSupportedError: 400,
71+
UnsupportedOperationError: 400,
7272
ContentTypeNotSupportedError: 415,
7373
InvalidAgentResponseError: 502,
74-
AuthenticatedExtendedCardNotConfiguredError: 404,
74+
AuthenticatedExtendedCardNotConfiguredError: 400,
75+
}
76+
77+
A2AErrorToTypeURI: dict[_A2AErrorType, str] = {
78+
TaskNotFoundError: 'https://a2a-protocol.org/errors/task-not-found',
79+
TaskNotCancelableError: 'https://a2a-protocol.org/errors/task-not-cancelable',
80+
PushNotificationNotSupportedError: 'https://a2a-protocol.org/errors/push-notification-not-supported',
81+
UnsupportedOperationError: 'https://a2a-protocol.org/errors/unsupported-operation',
82+
ContentTypeNotSupportedError: 'https://a2a-protocol.org/errors/content-type-not-supported',
83+
InvalidAgentResponseError: 'https://a2a-protocol.org/errors/invalid-agent-response',
84+
AuthenticatedExtendedCardNotConfiguredError: 'https://a2a-protocol.org/errors/extended-agent-card-not-configured',
85+
}
86+
87+
A2AErrorToTitle: dict[_A2AErrorType, str] = {
88+
JSONRPCError: 'JSON RPC Error',
89+
JSONParseError: 'JSON Parse Error',
90+
InvalidRequestError: 'Invalid Request Error',
91+
MethodNotFoundError: 'Method Not Found Error',
92+
InvalidParamsError: 'Invalid Params Error',
93+
InternalError: 'Internal Error',
94+
JSONRPCInternalError: 'Internal Error',
95+
TaskNotFoundError: 'Task Not Found',
96+
TaskNotCancelableError: 'Task Not Cancelable',
97+
PushNotificationNotSupportedError: 'Push Notification Not Supported',
98+
UnsupportedOperationError: 'Unsupported Operation',
99+
ContentTypeNotSupportedError: 'Content Type Not Supported',
100+
InvalidAgentResponseError: 'Invalid Agent Response',
101+
AuthenticatedExtendedCardNotConfiguredError: 'Extended Agent Card Not Configured',
75102
}
76103

77104

105+
def _build_problem_details_response(error: A2AError) -> JSONResponse:
106+
"""Helper to convert exceptions to RFC 9457 Problem Details responses."""
107+
error_type = cast('_A2AErrorType', type(error))
108+
http_code = A2AErrorToHttpStatus.get(error_type, 500)
109+
type_uri = A2AErrorToTypeURI.get(error_type, 'about:blank')
110+
title = A2AErrorToTitle.get(error_type, error.__class__.__name__)
111+
112+
log_level = (
113+
logging.ERROR if isinstance(error, InternalError) else logging.WARNING
114+
)
115+
logger.log(
116+
log_level,
117+
"Request error: Code=%s, Message='%s'%s",
118+
getattr(error, 'code', 'N/A'),
119+
getattr(error, 'message', str(error)),
120+
', Data=' + str(getattr(error, 'data', ''))
121+
if getattr(error, 'data', None)
122+
else '',
123+
)
124+
125+
payload = {
126+
'type': type_uri,
127+
'title': title,
128+
'status': http_code,
129+
'detail': getattr(error, 'message', str(error)),
130+
}
131+
132+
data = getattr(error, 'data', None)
133+
if isinstance(data, dict):
134+
for key, value in data.items():
135+
if key not in payload:
136+
payload[key] = value
137+
138+
return JSONResponse(
139+
content=payload,
140+
status_code=http_code,
141+
media_type='application/problem+json',
142+
)
143+
144+
78145
def rest_error_handler(
79146
func: Callable[..., Awaitable[Response]],
80147
) -> Callable[..., Awaitable[Response]]:
@@ -85,37 +152,18 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response:
85152
try:
86153
return await func(*args, **kwargs)
87154
except A2AError as error:
88-
http_code = A2AErrorToHttpStatus.get(
89-
cast('_A2AErrorType', type(error)), 500
90-
)
91-
92-
log_level = (
93-
logging.ERROR
94-
if isinstance(error, InternalError)
95-
else logging.WARNING
96-
)
97-
logger.log(
98-
log_level,
99-
"Request error: Code=%s, Message='%s'%s",
100-
getattr(error, 'code', 'N/A'),
101-
getattr(error, 'message', str(error)),
102-
', Data=' + str(getattr(error, 'data', ''))
103-
if getattr(error, 'data', None)
104-
else '',
105-
)
106-
# TODO(#722): Standardize error response format.
107-
return JSONResponse(
108-
content={
109-
'message': getattr(error, 'message', str(error)),
110-
'type': type(error).__name__,
111-
},
112-
status_code=http_code,
113-
)
155+
return _build_problem_details_response(error)
114156
except Exception:
115157
logger.exception('Unknown error occurred')
116158
return JSONResponse(
117-
content={'message': 'unknown exception', 'type': 'Exception'},
159+
content={
160+
'type': 'about:blank',
161+
'title': 'Internal Error',
162+
'status': 500,
163+
'detail': 'Unknown exception',
164+
},
118165
status_code=500,
166+
media_type='application/problem+json',
119167
)
120168

121169
return wrapper

tests/server/apps/rest/test_rest_fastapi_app.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22

33
from typing import Any
4-
from unittest.mock import MagicMock
4+
from unittest.mock import MagicMock, patch
55

66
import pytest
77

@@ -23,6 +23,7 @@
2323
TaskState,
2424
TaskStatus,
2525
)
26+
from a2a.utils.errors import InternalError
2627

2728

2829
logger = logging.getLogger(__name__)
@@ -396,5 +397,54 @@ async def test_send_message_rejected_task(
396397
assert expected_response == actual_response
397398

398399

400+
@pytest.mark.anyio
401+
async def test_global_http_exception_handler_returns_problem_details(
402+
client: AsyncClient,
403+
) -> None:
404+
"""Test that a standard FastAPI 404 is transformed into RFC 9457 format."""
405+
406+
# Send a request to an endpoint that does not exist
407+
response = await client.get('/non-existent-route')
408+
409+
# Verify it returns a 404, but in the new RFC 9457 format
410+
assert response.status_code == 404
411+
assert response.headers.get('content-type') == 'application/problem+json'
412+
413+
data = response.json()
414+
assert data['type'] == 'about:blank'
415+
assert data['title'] == 'HTTP Error'
416+
assert data['status'] == 404
417+
assert 'Not Found' in data['detail']
418+
419+
420+
@pytest.mark.anyio
421+
async def test_get_agent_card_error_handling(
422+
client: AsyncClient,
423+
) -> None:
424+
"""Test that the agent card endpoint properly catches and formats A2A errors."""
425+
426+
# Mock the REST adapter to simulate an internal failure when fetching the card
427+
with patch(
428+
'a2a.server.apps.rest.rest_adapter.RESTAdapter.handle_get_agent_card',
429+
side_effect=InternalError(
430+
message='Failed to load customized agent card'
431+
),
432+
):
433+
# In the fixtures, the agent card URL is set to /well-known/agent.json
434+
response = await client.get('/well-known/agent.json')
435+
436+
# Verify the error was caught and serialized cleanly
437+
assert response.status_code == 500
438+
assert (
439+
response.headers.get('content-type') == 'application/problem+json'
440+
)
441+
442+
data = response.json()
443+
assert data['type'] == 'about:blank'
444+
assert data['title'] == 'Internal Error'
445+
assert data['status'] == 500
446+
assert data['detail'] == 'Failed to load customized agent card'
447+
448+
399449
if __name__ == '__main__':
400450
pytest.main([__file__])

tests/utils/test_error_handlers.py

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Tests for a2a.utils.error_handlers module."""
22

33
from unittest.mock import patch
4-
54
import pytest
65

76
from a2a.types import (
@@ -14,15 +13,18 @@
1413
)
1514
from a2a.utils.error_handlers import (
1615
A2AErrorToHttpStatus,
16+
A2AErrorToTitle,
17+
A2AErrorToTypeURI,
1718
rest_error_handler,
1819
rest_stream_error_handler,
1920
)
2021

2122

2223
class MockJSONResponse:
23-
def __init__(self, content, status_code):
24+
def __init__(self, content, status_code, media_type=None):
2425
self.content = content
2526
self.status_code = status_code
27+
self.media_type = media_type
2628

2729

2830
@pytest.mark.asyncio
@@ -39,9 +41,39 @@ async def failing_func():
3941

4042
assert isinstance(result, MockJSONResponse)
4143
assert result.status_code == 400
44+
assert result.media_type == 'application/problem+json'
4245
assert result.content == {
43-
'message': 'Bad request',
44-
'type': 'InvalidRequestError',
46+
'type': 'about:blank',
47+
'title': 'Invalid Request Error',
48+
'status': 400,
49+
'detail': 'Bad request',
50+
}
51+
52+
53+
@pytest.mark.asyncio
54+
async def test_rest_error_handler_with_data_extensions():
55+
"""Test rest_error_handler maps A2AError.data to extension fields."""
56+
error = TaskNotFoundError(message='Task not found')
57+
# Dynamically attach data since __init__ no longer accepts it
58+
error.data = {'taskId': '123', 'retry': False}
59+
60+
@rest_error_handler
61+
async def failing_func():
62+
raise error
63+
64+
with patch('a2a.utils.error_handlers.JSONResponse', MockJSONResponse):
65+
result = await failing_func()
66+
67+
assert isinstance(result, MockJSONResponse)
68+
assert result.status_code == 404
69+
assert result.media_type == 'application/problem+json'
70+
assert result.content == {
71+
'type': 'https://a2a-protocol.org/errors/task-not-found',
72+
'title': 'Task Not Found',
73+
'status': 404,
74+
'detail': 'Task not found',
75+
'taskId': '123',
76+
'retry': False,
4577
}
4678

4779

@@ -58,9 +90,12 @@ async def failing_func():
5890

5991
assert isinstance(result, MockJSONResponse)
6092
assert result.status_code == 500
93+
assert result.media_type == 'application/problem+json'
6194
assert result.content == {
62-
'message': 'unknown exception',
63-
'type': 'Exception',
95+
'type': 'about:blank',
96+
'title': 'Internal Error',
97+
'status': 500,
98+
'detail': 'Unknown exception',
6499
}
65100

66101

@@ -91,9 +126,20 @@ async def failing_stream():
91126
await failing_stream()
92127

93128

94-
def test_a2a_error_to_http_status_mapping():
95-
"""Test A2AErrorToHttpStatus mapping."""
129+
def test_a2a_error_mappings():
130+
"""Test A2A error mappings."""
131+
# HTTP Status
96132
assert A2AErrorToHttpStatus[InvalidRequestError] == 400
97133
assert A2AErrorToHttpStatus[MethodNotFoundError] == 404
98134
assert A2AErrorToHttpStatus[TaskNotFoundError] == 404
99135
assert A2AErrorToHttpStatus[InternalError] == 500
136+
137+
# Type URI
138+
assert (
139+
A2AErrorToTypeURI[TaskNotFoundError]
140+
== 'https://a2a-protocol.org/errors/task-not-found'
141+
)
142+
143+
# Title
144+
assert A2AErrorToTitle[TaskNotFoundError] == 'Task Not Found'
145+
assert A2AErrorToTitle[InvalidRequestError] == 'Invalid Request Error'

0 commit comments

Comments
 (0)