diff --git a/src/connectrpc/_protocol.py b/src/connectrpc/_protocol.py index 7009f29..eb69a2d 100644 --- a/src/connectrpc/_protocol.py +++ b/src/connectrpc/_protocol.py @@ -6,7 +6,9 @@ from http import HTTPStatus from typing import TYPE_CHECKING, Protocol, TypeVar, cast +from google.protobuf import symbol_database from google.protobuf.any_pb2 import Any +from google.protobuf.json_format import MessageToDict from ._compression import Compression from .code import Code @@ -157,19 +159,29 @@ def to_http_status(self) -> ExtendedHTTPStatus: def to_dict(self) -> dict: data: dict = {"code": self.code.value, "message": self.message} if self.details: - details: list[dict[str, str]] = [] + details: list[dict] = [] for detail in self.details: if detail.type_url.startswith("type.googleapis.com/"): detail_type = detail.type_url[len("type.googleapis.com/") :] else: detail_type = detail.type_url - details.append( - { - "type": detail_type, - # Connect requires unpadded base64 - "value": b64encode(detail.value).decode("utf-8").rstrip("="), - } - ) + detail_dict: dict = { + "type": detail_type, + # Connect requires unpadded base64 + "value": b64encode(detail.value).decode("utf-8").rstrip("="), + } + # Try to produce debug info, but expect failure when we don't + # have descriptors for the message type. + debug = None + try: + msg_instance = symbol_database.Default().GetSymbol(detail_type)() + if detail.Unpack(msg_instance): + debug = MessageToDict(msg_instance) + except Exception: + debug = None + if debug is not None: + detail_dict["debug"] = debug + details.append(detail_dict) data["details"] = details return data diff --git a/test/test_details.py b/test/test_details.py index b6d5ee2..5c35f40 100644 --- a/test/test_details.py +++ b/test/test_details.py @@ -3,10 +3,13 @@ from typing import NoReturn import pytest +from google.protobuf.any_pb2 import Any as AnyPb +from google.protobuf.duration_pb2 import Duration from google.protobuf.struct_pb2 import Struct, Value from pyqwest import Client, SyncClient from pyqwest.testing import ASGITransport, WSGITransport +from connectrpc._protocol import ConnectWireError from connectrpc.code import Code from connectrpc.errors import ConnectError, pack_any @@ -81,3 +84,49 @@ async def make_hat(self, request, ctx) -> NoReturn: s1 = Struct() assert exc_info.value.details[1].Unpack(s1) assert s1.fields["color"].string_value == "red" + + +def test_error_detail_debug_field() -> None: + """Debug field is populated when proto descriptors are available.""" + wire_error = ConnectWireError.from_exception( + ConnectError( + Code.RESOURCE_EXHAUSTED, + "Resource exhausted", + details=[Struct(fields={"animal": Value(string_value="bear")})], + ) + ) + data = wire_error.to_dict() + assert len(data["details"]) == 1 + detail = data["details"][0] + assert "debug" in detail + # Struct uses proto-JSON well-known type mapping: becomes a plain JSON object + assert detail["debug"] == {"animal": "bear"} + + +def test_error_detail_debug_field_well_known_type() -> None: + """Debug field uses proto-JSON well-known type representation (e.g. Duration as string).""" + wire_error = ConnectWireError.from_exception( + ConnectError( + Code.RESOURCE_EXHAUSTED, "Resource exhausted", details=[Duration(seconds=1)] + ) + ) + data = wire_error.to_dict() + assert len(data["details"]) == 1 + detail = data["details"][0] + assert "debug" in detail + # Duration uses proto-JSON well-known type mapping: serializes as "1s" + assert detail["debug"] == "1s" + + +def test_error_detail_debug_field_absent_for_unknown_type() -> None: + """Debug field is omitted when no descriptor is available for the type.""" + unknown_detail = AnyPb( + type_url="type.googleapis.com/completely.Unknown.Message", value=b"\x08\x01" + ) + wire_error = ConnectWireError( + code=Code.INTERNAL, message="test", details=[unknown_detail] + ) + data = wire_error.to_dict() + assert len(data["details"]) == 1 + detail = data["details"][0] + assert "debug" not in detail