Skip to content

Commit 1a45817

Browse files
authored
Expose error details with a class (#257)
Signed-off-by: Anuraag Agrawal <anuraaga@gmail.com>
1 parent f6dca4a commit 1a45817

5 files changed

Lines changed: 91 additions & 43 deletions

File tree

conformance/test/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ async def send_unary_request(
667667
except ConnectError as e:
668668
test_response.response.error.code = _convert_code(e.code)
669669
test_response.response.error.message = e.message
670-
test_response.response.error.details.extend(e.details)
670+
test_response.response.error.details.extend(d._any for d in e.details)
671671
except (asyncio.CancelledError, Exception) as e:
672672
traceback.print_tb(e.__traceback__, file=sys.stderr)
673673
test_response.error.message = str(e)

src/connectrpc/_protocol.py

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@
66
from http import HTTPStatus
77
from typing import TYPE_CHECKING, Protocol, TypeVar, cast
88

9-
from google.protobuf import symbol_database
109
from google.protobuf.any_pb2 import Any
1110
from google.protobuf.json_format import MessageToDict
1211

1312
from ._compression import Compression
1413
from .code import Code
15-
from .errors import ConnectError
14+
from .errors import ConnectError, ErrorDetail
1615

1716
if TYPE_CHECKING:
1817
from collections.abc import Mapping, Sequence
@@ -91,7 +90,7 @@ def from_http_status(status: HTTPStatus) -> ExtendedHTTPStatus:
9190
class ConnectWireError:
9291
code: Code
9392
message: str
94-
details: Sequence[Any]
93+
details: Sequence[ErrorDetail]
9594

9695
@staticmethod
9796
def from_exception(exc: Exception) -> ConnectWireError:
@@ -122,7 +121,7 @@ def from_dict(
122121
else:
123122
code = _http_status_code_to_error.get(http_status, Code.UNKNOWN)
124123
message = data.get("message", "")
125-
details: Sequence[Any] = ()
124+
details: Sequence[ErrorDetail] = ()
126125
details_json = cast("list[dict[str, str]] | None", data.get("details"))
127126
if details_json:
128127
details = []
@@ -133,9 +132,11 @@ def from_dict(
133132
# Ignore malformed details
134133
continue
135134
details.append(
136-
Any(
137-
type_url="type.googleapis.com/" + detail_type,
138-
value=b64decode(detail_value + "==="),
135+
ErrorDetail(
136+
Any(
137+
type_url="type.googleapis.com/" + detail_type,
138+
value=b64decode(detail_value + "==="),
139+
)
139140
)
140141
)
141142
return ConnectWireError(code, message, details)
@@ -161,26 +162,17 @@ def to_dict(self) -> dict:
161162
if self.details:
162163
details: list[dict] = []
163164
for detail in self.details:
164-
if detail.type_url.startswith("type.googleapis.com/"):
165-
detail_type = detail.type_url[len("type.googleapis.com/") :]
166-
else:
167-
detail_type = detail.type_url
168165
detail_dict: dict = {
169-
"type": detail_type,
166+
"type": detail.type_name,
170167
# Connect requires unpadded base64
171-
"value": b64encode(detail.value).decode("utf-8").rstrip("="),
168+
"value": b64encode(detail.message_bytes)
169+
.decode("utf-8")
170+
.rstrip("="),
172171
}
173172
# Try to produce debug info, but expect failure when we don't
174173
# have descriptors for the message type.
175-
debug = None
176-
try:
177-
msg_instance = symbol_database.Default().GetSymbol(detail_type)()
178-
if detail.Unpack(msg_instance):
179-
debug = MessageToDict(msg_instance)
180-
except Exception:
181-
debug = None
182-
if debug is not None:
183-
detail_dict["debug"] = debug
174+
if (debug := detail.value()) is not None:
175+
detail_dict["debug"] = MessageToDict(debug)
184176
details.append(detail_dict)
185177
data["details"] = details
186178
return data

src/connectrpc/_protocol_grpc.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,9 @@ def end(self, user_trailers: Headers, error: ConnectWireError | None) -> Headers
169169
trailers["grpc-message"] = message
170170
if error.details:
171171
grpc_status = Status(
172-
code=int(status), message=error.message, details=error.details
172+
code=int(status),
173+
message=error.message,
174+
details=[d._any for d in error.details], # noqa: SLF001
173175
)
174176
grpc_status_bin = (
175177
b64encode(grpc_status.SerializeToString()).decode().rstrip("=")

src/connectrpc/errors.py

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,73 @@
11
from __future__ import annotations
22

3-
__all__ = ["ConnectError"]
3+
__all__ = ["ConnectError", "ErrorDetail"]
44

55

6-
from typing import TYPE_CHECKING
6+
from typing import TYPE_CHECKING, TypeVar, overload
77

8+
from google.protobuf import symbol_database
89
from google.protobuf.any_pb2 import Any
10+
from google.protobuf.message import Message
911

1012
if TYPE_CHECKING:
1113
from collections.abc import Iterable, Sequence
1214

13-
from google.protobuf.message import Message
14-
1515
from .code import Code
1616

17+
T = TypeVar("T", bound=Message)
18+
19+
20+
class ErrorDetail:
21+
"""A self-describing Protobuf message attached to a [ConnectError][].
22+
23+
Error details are sent over the network to clients, which can then work with
24+
strongly-typed data rather than trying to parse a complex error message. For
25+
example, you might use details to send a localized error message or retry
26+
parameters to a client.
27+
"""
28+
29+
def __init__(self, message: Message) -> None:
30+
if isinstance(message, Any):
31+
self._message = None
32+
self._any = message
33+
return
34+
self._message = message
35+
self._any = pack_any(message)
36+
37+
@property
38+
def type_name(self) -> str:
39+
"""The fully-qualified name of the details Protobuf message (for example, acme.foo.v1.FooDetail)."""
40+
return self._any.type_url.removeprefix("type.googleapis.com/")
41+
42+
@property
43+
def message_bytes(self) -> bytes:
44+
"""The Protobuf message serialized as bytes."""
45+
return self._any.value
46+
47+
@overload
48+
def value(self) -> Message | None: ...
49+
50+
@overload
51+
def value(self, typ: type[T], /) -> T | None: ...
52+
53+
def value(self, desc: type[Message] | None = None) -> Message | None:
54+
"""The details message as a Protobuf message, or None if it cannot be deserialized."""
55+
if self._message:
56+
return self._message
57+
if isinstance(desc, type):
58+
msg = desc()
59+
if self._any.Unpack(msg):
60+
return msg
61+
return None
62+
try:
63+
detail_type = self._any.type_url.removeprefix("type.googleapis.com/")
64+
msg_instance = symbol_database.Default().GetSymbol(detail_type)()
65+
if self._any.Unpack(msg_instance):
66+
return msg_instance
67+
return None
68+
except Exception:
69+
return None
70+
1771

1872
class ConnectError(Exception):
1973
"""An exception in a Connect RPC.
@@ -25,7 +79,7 @@ class ConnectError(Exception):
2579
"""
2680

2781
def __init__(
28-
self, code: Code, message: str, details: Iterable[Message] = ()
82+
self, code: Code, message: str, details: Iterable[Message | ErrorDetail] = ()
2983
) -> None:
3084
"""
3185
Creates a new Connect error.
@@ -40,7 +94,7 @@ def __init__(
4094
self._message = message
4195

4296
self._details = (
43-
[m if isinstance(m, Any) else pack_any(m) for m in details]
97+
[m if isinstance(m, ErrorDetail) else ErrorDetail(m) for m in details]
4498
if details
4599
else ()
46100
)
@@ -54,7 +108,7 @@ def message(self) -> str:
54108
return self._message
55109

56110
@property
57-
def details(self) -> Sequence[Any]:
111+
def details(self) -> Sequence[ErrorDetail]:
58112
return self._details
59113

60114

test/test_details.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from connectrpc._protocol import ConnectWireError
1313
from connectrpc.code import Code
14-
from connectrpc.errors import ConnectError, pack_any
14+
from connectrpc.errors import ConnectError, ErrorDetail
1515

1616
from .haberdasher_connect import (
1717
Haberdasher,
@@ -32,7 +32,7 @@ def make_hat(self, request, ctx) -> NoReturn:
3232
"Resource exhausted",
3333
details=[
3434
Struct(fields={"animal": Value(string_value="bear")}),
35-
pack_any(Struct(fields={"color": Value(string_value="red")})),
35+
ErrorDetail(Struct(fields={"color": Value(string_value="red")})),
3636
],
3737
)
3838

@@ -47,11 +47,11 @@ def make_hat(self, request, ctx) -> NoReturn:
4747
assert exc_info.value.code == Code.RESOURCE_EXHAUSTED
4848
assert exc_info.value.message == "Resource exhausted"
4949
assert len(exc_info.value.details) == 2
50-
s0 = Struct()
51-
assert exc_info.value.details[0].Unpack(s0)
50+
s0 = exc_info.value.details[0].value(Struct)
51+
assert s0 is not None
5252
assert s0.fields["animal"].string_value == "bear"
53-
s1 = Struct()
54-
assert exc_info.value.details[1].Unpack(s1)
53+
s1 = exc_info.value.details[1].value(Struct)
54+
assert s1 is not None
5555
assert s1.fields["color"].string_value == "red"
5656

5757

@@ -64,7 +64,7 @@ async def make_hat(self, request, ctx) -> NoReturn:
6464
"Resource exhausted",
6565
details=[
6666
Struct(fields={"animal": Value(string_value="bear")}),
67-
pack_any(Struct(fields={"color": Value(string_value="red")})),
67+
ErrorDetail(Struct(fields={"color": Value(string_value="red")})),
6868
],
6969
)
7070

@@ -78,11 +78,11 @@ async def make_hat(self, request, ctx) -> NoReturn:
7878
assert exc_info.value.code == Code.RESOURCE_EXHAUSTED
7979
assert exc_info.value.message == "Resource exhausted"
8080
assert len(exc_info.value.details) == 2
81-
s0 = Struct()
82-
assert exc_info.value.details[0].Unpack(s0)
81+
s0 = exc_info.value.details[0].value(Struct)
82+
assert s0 is not None
8383
assert s0.fields["animal"].string_value == "bear"
84-
s1 = Struct()
85-
assert exc_info.value.details[1].Unpack(s1)
84+
s1 = exc_info.value.details[1].value(Struct)
85+
assert s1 is not None
8686
assert s1.fields["color"].string_value == "red"
8787

8888

@@ -124,7 +124,7 @@ def test_error_detail_debug_field_absent_for_unknown_type() -> None:
124124
type_url="type.googleapis.com/completely.Unknown.Message", value=b"\x08\x01"
125125
)
126126
wire_error = ConnectWireError(
127-
code=Code.INTERNAL, message="test", details=[unknown_detail]
127+
code=Code.INTERNAL, message="test", details=[ErrorDetail(unknown_detail)]
128128
)
129129
data = wire_error.to_dict()
130130
assert len(data["details"]) == 1

0 commit comments

Comments
 (0)