Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/actions/spelling/allow.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ agentic
AGrpc
aio
aiomysql
AIP
alg
amannn
aproject
Expand Down
41 changes: 29 additions & 12 deletions src/a2a/client/transports/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,42 +8,38 @@

from google.protobuf.json_format import MessageToDict, Parse, ParseDict

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, MethodNotFoundError
from a2a.utils.errors import A2A_REASON_TO_ERROR, MethodNotFoundError

Check notice on line 37 in src/a2a/client/transports/rest.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Copy/pasted code

see src/a2a/client/transports/jsonrpc.py (12-38)
from a2a.utils.telemetry import SpanKind, trace_class


logger = logging.getLogger(__name__)

_A2A_ERROR_NAME_TO_CLS = {
error_type.__name__: error_type for error_type in JSON_RPC_ERROR_CODE_MAP
}


@trace_class(kind=SpanKind.CLIENT)
class RestTransport(ClientTransport):
Expand Down Expand Up @@ -297,15 +293,36 @@
def _handle_http_error(self, e: httpx.HTTPStatusError) -> NoReturn:
"""Handles HTTP status errors and raises the appropriate A2AError."""
try:
error_data = e.response.json()
error_type = error_data.get('type')
message = error_data.get('message', str(e))
error_payload = e.response.json()
error_data = error_payload.get('error', {})

if isinstance(error_type, str):
# TODO(#723): Resolving imports by name is temporary until proper error handling structure is added in #723.
exception_cls = _A2A_ERROR_NAME_TO_CLS.get(error_type)
message = error_data.get('message', str(e))
details = error_data.get('details', [])
if not isinstance(details, list):
details = []

# The `details` array can contain multiple different error objects.
# We extract the first `ErrorInfo` object because it contains the
# specific `reason` code needed to map this back to a Python A2AError.
error_info = {}
for d in details:
if (
isinstance(d, dict)
and d.get('@type')
== 'type.googleapis.com/google.rpc.ErrorInfo'
):
error_info = d
break
reason = error_info.get('reason')
metadata = error_info.get('metadata') or {}

if isinstance(reason, str):
exception_cls = A2A_REASON_TO_ERROR.get(reason)
if exception_cls:
raise exception_cls(message) from e
exc = exception_cls(message)
if metadata:
exc.data = metadata
raise exc from e
except (json.JSONDecodeError, ValueError):
pass

Expand Down
48 changes: 48 additions & 0 deletions src/a2a/server/apps/rest/fastapi_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@
if TYPE_CHECKING:
from fastapi import APIRouter, FastAPI, Request, Response
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException

_package_fastapi_installed = True
else:
try:
from fastapi import APIRouter, FastAPI, Request, Response
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException

_package_fastapi_installed = True
except ImportError:
APIRouter = Any
FastAPI = Any
Request = Any
Response = Any
StarletteHTTPException = Any

_package_fastapi_installed = False

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


_HTTP_TO_GRPC_STATUS_MAP = {
400: 'INVALID_ARGUMENT',
401: 'UNAUTHENTICATED',
403: 'PERMISSION_DENIED',
404: 'NOT_FOUND',
405: 'UNIMPLEMENTED',
409: 'ALREADY_EXISTS',
415: 'INVALID_ARGUMENT',
422: 'INVALID_ARGUMENT',
500: 'INTERNAL',
501: 'UNIMPLEMENTED',
502: 'INTERNAL',
503: 'UNAVAILABLE',
504: 'DEADLINE_EXCEEDED',
}


class A2ARESTFastAPIApplication:
"""A FastAPI application implementing the A2A protocol server REST endpoints.

Expand Down Expand Up @@ -121,6 +141,34 @@ def build(
A configured FastAPI application instance.
"""
app = FastAPI(**kwargs)

@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(
request: Request, exc: StarletteHTTPException
) -> Response:
"""Catches framework-level HTTP exceptions.

For example, 404 Not Found for bad routes, 422 Unprocessable Entity
for schema validation, and formats them into the A2A standard
google.rpc.Status JSON format (AIP-193).
"""
grpc_status = _HTTP_TO_GRPC_STATUS_MAP.get(
exc.status_code, 'UNKNOWN'
)
return JSONResponse(
status_code=exc.status_code,
content={
'error': {
'code': exc.status_code,
'status': grpc_status,
'message': str(exc.detail)
if hasattr(exc, 'detail')
else 'HTTP Exception',
}
},
media_type='application/json',
)

if self.enable_v0_3_compat and self._v03_adapter:
v03_adapter = self._v03_adapter
v03_router = APIRouter()
Expand Down
133 changes: 59 additions & 74 deletions src/a2a/utils/error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import logging

from collections.abc import Awaitable, Callable, Coroutine
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any


if TYPE_CHECKING:
Expand All @@ -17,70 +17,40 @@

from google.protobuf.json_format import ParseError

from a2a.server.jsonrpc_models import (
InternalError as JSONRPCInternalError,
)
from a2a.server.jsonrpc_models import (
JSONParseError,
JSONRPCError,
)
from a2a.utils.errors import (
A2A_REST_ERROR_MAPPING,
A2AError,
ContentTypeNotSupportedError,
ExtendedAgentCardNotConfiguredError,
ExtensionSupportRequiredError,
InternalError,
InvalidAgentResponseError,
InvalidParamsError,
InvalidRequestError,
MethodNotFoundError,
PushNotificationNotSupportedError,
TaskNotCancelableError,
TaskNotFoundError,
UnsupportedOperationError,
VersionNotSupportedError,
RestErrorMap,
)


logger = logging.getLogger(__name__)

_A2AErrorType = (
type[JSONRPCError]
| type[JSONParseError]
| type[InvalidRequestError]
| type[MethodNotFoundError]
| type[InvalidParamsError]
| type[InternalError]
| type[JSONRPCInternalError]
| type[TaskNotFoundError]
| type[TaskNotCancelableError]
| type[PushNotificationNotSupportedError]
| type[UnsupportedOperationError]
| type[ContentTypeNotSupportedError]
| type[InvalidAgentResponseError]
| type[ExtendedAgentCardNotConfiguredError]
| type[ExtensionSupportRequiredError]
| type[VersionNotSupportedError]
)

A2AErrorToHttpStatus: dict[_A2AErrorType, int] = {
JSONRPCError: 500,
JSONParseError: 400,
InvalidRequestError: 400,
MethodNotFoundError: 404,
InvalidParamsError: 422,
InternalError: 500,
JSONRPCInternalError: 500,
TaskNotFoundError: 404,
TaskNotCancelableError: 409,
PushNotificationNotSupportedError: 501,
UnsupportedOperationError: 501,
ContentTypeNotSupportedError: 415,
InvalidAgentResponseError: 502,
ExtendedAgentCardNotConfiguredError: 400,
ExtensionSupportRequiredError: 400,
VersionNotSupportedError: 400,
}
def _build_error_payload(
code: int,
status: str,
message: str,
reason: str | None = None,
metadata: 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 {},
}
]
return {'error': payload}


def rest_error_handler(
Expand All @@ -93,46 +63,63 @@
try:
return await func(*args, **kwargs)
except A2AError as error:
http_code = A2AErrorToHttpStatus.get(
cast('_A2AErrorType', type(error)), 500
mapping = A2A_REST_ERROR_MAPPING.get(
type(error), RestErrorMap(500, 'INTERNAL', 'INTERNAL_ERROR')
)
http_code = mapping.http_code
grpc_status = mapping.grpc_status
reason = mapping.reason

log_level = (
logging.ERROR
if isinstance(error, InternalError)
else logging.WARNING
)
logger.log(
log_level,
"Request error: Code=%s, Message='%s'%s",
getattr(error, 'code', 'N/A'),
getattr(error, 'message', str(error)),
', Data=' + str(getattr(error, 'data', ''))
if getattr(error, 'data', None)
else '',
f', Data={error.data}' if error.data else '',
)
# TODO(#722): Standardize error response format.

# SECURITY WARNING: Data attached to A2AError.data is serialized unaltered and exposed publicly to the client in the REST API response.

Check notice on line 86 in src/a2a/utils/error_handlers.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Copy/pasted code

see src/a2a/utils/error_handlers.py (138-150)
metadata = getattr(error, 'data', None) or {}

return JSONResponse(
content={
'message': getattr(error, 'message', str(error)),
'type': type(error).__name__,
},
content=_build_error_payload(
code=http_code,
status=grpc_status,
message=getattr(error, 'message', str(error)),
reason=reason,
metadata=metadata,
),
status_code=http_code,
media_type='application/json',
)
except ParseError as error:
logger.warning('Parse error: %s', str(error))
return JSONResponse(
content={
'message': str(error),
'type': 'ParseError',
},
content=_build_error_payload(
code=400,
status='INVALID_ARGUMENT',
message=str(error),
reason='INVALID_REQUEST',
metadata={},
),
status_code=400,
media_type='application/json',
)
except Exception:
logger.exception('Unknown error occurred')
return JSONResponse(
content={'message': 'unknown exception', 'type': 'Exception'},
content=_build_error_payload(
code=500,
status='INTERNAL',
message='unknown exception',
),
status_code=500,
media_type='application/json',
)

return wrapper
Expand All @@ -148,21 +135,19 @@
try:
return await func(*args, **kwargs)
except A2AError as error:
log_level = (
logging.ERROR
if isinstance(error, InternalError)
else logging.WARNING
)
logger.log(
log_level,
"Request error: Code=%s, Message='%s'%s",
getattr(error, 'code', 'N/A'),
getattr(error, 'message', str(error)),
', Data=' + str(getattr(error, 'data', ''))
if getattr(error, 'data', None)
else '',
f', Data={error.data}' if error.data else '',
)
# Since the stream has started, we can't return a JSONResponse.

Check notice on line 150 in src/a2a/utils/error_handlers.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

Copy/pasted code

see src/a2a/utils/error_handlers.py (73-86)
# Instead, we run the error handling logic (provides logging)
# and reraise the error and let server framework manage
raise error
Expand Down
Loading
Loading