diff --git a/pyproject.toml b/pyproject.toml index dc64177b1..c1fbb9129 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ classifiers = [ http-server = ["sse-starlette", "starlette"] fastapi = ["a2a-sdk[http-server]", "fastapi>=0.115.2"] encryption = ["cryptography>=43.0.0"] -grpc = ["grpcio>=1.60", "grpcio-tools>=1.60", "grpcio-status>=1.60", "grpcio_reflection>=1.7.0"] +grpc = ["grpcio>=1.60", "grpcio-tools>=1.60", "grpcio_reflection>=1.7.0"] telemetry = ["opentelemetry-api>=1.33.0", "opentelemetry-sdk>=1.33.0"] postgresql = ["sqlalchemy[asyncio,postgresql-asyncpg]>=2.0.0"] mysql = ["sqlalchemy[asyncio,aiomysql]>=2.0.0"] @@ -94,7 +94,7 @@ filterwarnings = [ # ResourceWarnings from asyncio event loop/socket cleanup during garbage collection # These appear intermittently between tests due to pytest-asyncio and sse-starlette timing "ignore:unclosed event loop:ResourceWarning", - "ignore:unclosed transport:ResourceWarning", + "ignore:unclosed transport:ResourceWarning", "ignore:unclosed NoReturn: +def _from_call(call: grpc.Call) -> status_pb2.Status | None: + """Extracts a google.rpc.status.Status message from a grpc.Call instance.""" + if not hasattr(call, 'trailing_metadata'): + return None + trailing_metadata = call.trailing_metadata() + if not trailing_metadata: + return None + for k, v in trailing_metadata: + if k == 'grpc-status-details-bin': + status = status_pb2.Status() + status.ParseFromString(v) + + if call.code().value[0] != status.code: + raise ValueError( + f'Mismatched status code: proto={status.code}, call={call.code()}' + ) + if call.details() != status.message: + raise ValueError( + f'Mismatched status message: proto={status.message!r}, call={call.details()!r}' + ) + return status + return None + +def _map_grpc_error(e: grpc.aio.AioRpcError) -> NoReturn: if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED: raise A2AClientTimeoutError('Client Request timed out') from e - # Use grpc_status to cleanly extract the rich Status from the call - status = rpc_status.from_call(cast('grpc.Call', e)) + status = _from_call(cast('grpc.Call', e)) data = None if status is not None: @@ -321,7 +342,6 @@ async def _call_grpc( context: ClientCallContext | None, **kwargs: Any, ) -> Any: - return await method( request, metadata=self._get_grpc_metadata(context), @@ -336,7 +356,6 @@ async def _call_grpc_stream( context: ClientCallContext | None, **kwargs: Any, ) -> AsyncGenerator[StreamResponse]: - stream = method( request, metadata=self._get_grpc_metadata(context), diff --git a/src/a2a/server/request_handlers/grpc_handler.py b/src/a2a/server/request_handlers/grpc_handler.py index fc7e03084..9b10d54bd 100644 --- a/src/a2a/server/request_handlers/grpc_handler.py +++ b/src/a2a/server/request_handlers/grpc_handler.py @@ -3,17 +3,15 @@ from abc import ABC, abstractmethod from collections.abc import AsyncIterable, Awaitable, Callable -from typing import TypeVar +from typing import NamedTuple, TypeVar try: import grpc # type: ignore[reportMissingModuleSource] import grpc.aio # type: ignore[reportMissingModuleSource] - - from grpc_status import rpc_status except ImportError as e: raise ImportError( - 'GrpcHandler requires grpcio, grpcio-tools, and grpcio-status to be installed. ' + 'GrpcHandler requires grpcio and grpcio-tools to be installed. ' 'Install with: ' "'pip install a2a-sdk[grpc]'" ) from e @@ -37,6 +35,29 @@ from a2a.utils.proto_utils import validation_errors_to_bad_request +class GrpcStatus(NamedTuple): + """Represents the gRPC status code, details, and trailing metadata.""" + + code: grpc.StatusCode + details: str + trailing_metadata: tuple + + +def _to_status(status: status_pb2.Status) -> GrpcStatus: + """Converts a google.rpc.status.Status message into its gRPC components.""" + for x in grpc.StatusCode: + if x.value[0] == status.code: + grpc_code = x + break + else: + raise ValueError(f'Invalid status code {status.code}') + + bin_data = status.SerializeToString() + metadata = (('grpc-status-details-bin', bin_data),) + + return GrpcStatus(grpc_code, status.message, metadata) + + logger = logging.getLogger(__name__) @@ -400,8 +421,8 @@ async def abort_context( ) status.details.append(bad_request_detail) - # Use grpc_status to safely generate standard trailing metadata - rich_status = rpc_status.to_status(status) + # Use local helper to safely generate standard trailing metadata + rich_status = _to_status(status) new_metadata: list[tuple[str, str | bytes]] = [] trailing = context.trailing_metadata() diff --git a/uv.lock b/uv.lock index 973555fc4..d09af8654 100644 --- a/uv.lock +++ b/uv.lock @@ -30,7 +30,6 @@ all = [ { name = "fastapi" }, { name = "grpcio" }, { name = "grpcio-reflection" }, - { name = "grpcio-status" }, { name = "grpcio-tools" }, { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, @@ -53,7 +52,6 @@ fastapi = [ grpc = [ { name = "grpcio" }, { name = "grpcio-reflection" }, - { name = "grpcio-status" }, { name = "grpcio-tools" }, ] http-server = [ @@ -117,8 +115,6 @@ requires-dist = [ { name = "grpcio", marker = "extra == 'grpc'", specifier = ">=1.60" }, { name = "grpcio-reflection", marker = "extra == 'all'", specifier = ">=1.7.0" }, { name = "grpcio-reflection", marker = "extra == 'grpc'", specifier = ">=1.7.0" }, - { name = "grpcio-status", marker = "extra == 'all'", specifier = ">=1.60" }, - { name = "grpcio-status", marker = "extra == 'grpc'", specifier = ">=1.60" }, { name = "grpcio-tools", marker = "extra == 'all'", specifier = ">=1.60" }, { name = "grpcio-tools", marker = "extra == 'grpc'", specifier = ">=1.60" }, { name = "httpx", specifier = ">=0.28.1" }, @@ -1000,20 +996,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/61/e472357ff5484f67c802568e70b3182df3d8eb1d0c2de38199d9a9a28bb2/grpcio_reflection-1.81.0-py3-none-any.whl", hash = "sha256:85322a9c1ab62d9823b1262a9d78d653b1710b99b5764cdcef2673cfe352b9c1", size = 22907, upload-time = "2026-06-01T06:00:16.714Z" }, ] -[[package]] -name = "grpcio-status" -version = "1.81.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/b6/cdc177114997d15c887fb09ccfd16705c8ceb8b4ca2487902b54a7bfd1af/grpcio_status-1.81.0.tar.gz", hash = "sha256:b6fe9788cfdd1f0f63c0528a1e0bfdb41e8ff0583e920d2d8e8888598c01bb69", size = 13900, upload-time = "2026-06-01T06:00:32.638Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/b7/5aa346bf1cdecd4ed64b86c10a4d5a089ce3da89145f8328caf0b22b240d/grpcio_status-1.81.0-py3-none-any.whl", hash = "sha256:10eb4c2309db902dc26c1873e80a821bf794be772c10dfd83030f7f59f165fab", size = 14634, upload-time = "2026-06-01T06:00:13.345Z" }, -] - [[package]] name = "grpcio-tools" version = "1.81.0"