Skip to content

Commit 668f39f

Browse files
authored
Add doc on error handling (#19)
Signed-off-by: i2y <[email protected]>
1 parent 0400967 commit 668f39f

File tree

2 files changed

+341
-0
lines changed

2 files changed

+341
-0
lines changed

docs/errors.md

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# Errors
2+
3+
Similar to the familiar "404 Not Found" and "500 Internal Server Error" status codes in HTTP, Connect uses a set of [16 error codes](https://connectrpc.com/docs/protocol#error-codes). These error codes are designed to work consistently across Connect, gRPC, and gRPC-Web protocols.
4+
5+
## Working with errors
6+
7+
Connect handlers raise errors using `ConnectError`:
8+
9+
=== "ASGI"
10+
11+
```python
12+
from connectrpc.code import Code
13+
from connectrpc.errors import ConnectError
14+
from connectrpc.request import RequestContext
15+
16+
async def greet(self, request: GreetRequest, ctx: RequestContext) -> GreetResponse:
17+
if not request.name:
18+
raise ConnectError(Code.INVALID_ARGUMENT, "name is required")
19+
return GreetResponse(greeting=f"Hello, {request.name}!")
20+
```
21+
22+
=== "WSGI"
23+
24+
```python
25+
from connectrpc.code import Code
26+
from connectrpc.errors import ConnectError
27+
from connectrpc.request import RequestContext
28+
29+
def greet(self, request: GreetRequest, ctx: RequestContext) -> GreetResponse:
30+
if not request.name:
31+
raise ConnectError(Code.INVALID_ARGUMENT, "name is required")
32+
return GreetResponse(greeting=f"Hello, {request.name}!")
33+
```
34+
35+
Clients catch errors the same way:
36+
37+
=== "Async"
38+
39+
```python
40+
from connectrpc.code import Code
41+
from connectrpc.errors import ConnectError
42+
43+
async with GreetServiceClient("http://localhost:8000") as client:
44+
try:
45+
response = await client.greet(GreetRequest(name=""))
46+
except ConnectError as e:
47+
if e.code == Code.INVALID_ARGUMENT:
48+
print(f"Invalid request: {e.message}")
49+
else:
50+
print(f"RPC failed: {e.code} - {e.message}")
51+
```
52+
53+
=== "Sync"
54+
55+
```python
56+
from connectrpc.code import Code
57+
from connectrpc.errors import ConnectError
58+
59+
with GreetServiceClientSync("http://localhost:8000") as client:
60+
try:
61+
response = client.greet(GreetRequest(name=""))
62+
except ConnectError as e:
63+
if e.code == Code.INVALID_ARGUMENT:
64+
print(f"Invalid request: {e.message}")
65+
else:
66+
print(f"RPC failed: {e.code} - {e.message}")
67+
```
68+
69+
## Error codes
70+
71+
Connect uses a set of [16 error codes](https://connectrpc.com/docs/protocol#error-codes). The `code` property of a `ConnectError` holds one of these codes. All error codes are available through the `Code` enumeration:
72+
73+
```python
74+
from connectrpc.code import Code
75+
76+
code = Code.INVALID_ARGUMENT
77+
code.value # "invalid_argument"
78+
79+
# Access by name
80+
Code["INVALID_ARGUMENT"] # Code.INVALID_ARGUMENT
81+
```
82+
83+
## Error messages
84+
85+
The `message` property contains a descriptive error message. In most cases, the message is provided by the backend implementing the service:
86+
87+
```python
88+
try:
89+
response = await client.greet(GreetRequest(name=""))
90+
except ConnectError as e:
91+
print(e.message) # "name is required"
92+
```
93+
94+
## Error details
95+
96+
Errors can include strongly-typed details using protobuf messages:
97+
98+
```python
99+
from connectrpc.code import Code
100+
from connectrpc.errors import ConnectError
101+
from connectrpc.request import RequestContext
102+
from google.protobuf.struct_pb2 import Struct, Value
103+
104+
async def create_user(self, request: CreateUserRequest, ctx: RequestContext) -> CreateUserResponse:
105+
if not request.email:
106+
error_detail = Struct(fields={
107+
"field": Value(string_value="email"),
108+
"issue": Value(string_value="Email is required")
109+
})
110+
111+
raise ConnectError(
112+
Code.INVALID_ARGUMENT,
113+
"Invalid user request",
114+
details=[error_detail]
115+
)
116+
# ... rest of implementation
117+
```
118+
119+
### Reading error details on the client
120+
121+
Error details are `google.protobuf.Any` messages that can be unpacked to their original types:
122+
123+
```python
124+
try:
125+
response = await client.some_method(request)
126+
except ConnectError as e:
127+
for detail in e.details:
128+
# Check the type before unpacking
129+
if detail.Is(Struct.DESCRIPTOR):
130+
unpacked = Struct()
131+
detail.Unpack(unpacked)
132+
print(f"Error detail: {unpacked}")
133+
```
134+
135+
### Standard error detail types
136+
137+
With `googleapis-common-protos` installed, you can use standard types like:
138+
139+
- `BadRequest`: Field violations in a request
140+
- `RetryInfo`: When to retry
141+
- `Help`: Links to documentation
142+
- `QuotaFailure`: Quota violations
143+
- `ErrorInfo`: Structured error metadata
144+
145+
Example:
146+
147+
```python
148+
from google.rpc.error_details_pb2 import BadRequest
149+
150+
bad_request = BadRequest()
151+
violation = bad_request.field_violations.add()
152+
violation.field = "email"
153+
violation.description = "Must be a valid email address"
154+
155+
raise ConnectError(
156+
Code.INVALID_ARGUMENT,
157+
"Invalid email format",
158+
details=[bad_request]
159+
)
160+
```
161+
162+
## HTTP representation
163+
164+
In the Connect protocol, errors are always JSON:
165+
166+
```http
167+
HTTP/1.1 400 Bad Request
168+
Content-Type: application/json
169+
170+
{
171+
"code": "invalid_argument",
172+
"message": "name is required",
173+
"details": [
174+
{
175+
"type": "google.protobuf.Struct",
176+
"value": "base64-encoded-protobuf"
177+
}
178+
]
179+
}
180+
```
181+
182+
The `details` array contains error detail messages, where each entry has:
183+
184+
- `type`: The fully-qualified protobuf message type (e.g., `google.protobuf.Struct`)
185+
- `value`: The protobuf message serialized in binary format and then base64-encoded
186+
187+
## See also
188+
189+
- [Interceptors](interceptors.md) for error transformation and logging
190+
- [Streaming](streaming.md) for stream-specific error handling
191+
- [Headers and trailers](headers-and-trailers.md) for attaching metadata to errors
192+
- [Usage guide](usage.md) for error handling best practices

docs/usage.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,152 @@ class ElizaServiceImpl:
159159
for message in request:
160160
yield eliza_pb2.ConverseResponse(sentence=f"You said: {message.sentence}")
161161
```
162+
163+
## Error Handling Best Practices
164+
165+
### Choosing appropriate error codes
166+
167+
Select error codes that accurately reflect the situation:
168+
169+
- Use `INVALID_ARGUMENT` for malformed requests that should never be retried
170+
- Use `FAILED_PRECONDITION` for requests that might succeed if the system state changes
171+
- Use `UNAVAILABLE` for transient failures that should be retried
172+
- Use `INTERNAL` sparingly - it indicates a bug in your code
173+
174+
For more detailed guidance on choosing error codes, see the [Connect protocol documentation](https://connectrpc.com/docs/protocol#error-codes).
175+
176+
### Providing helpful error messages
177+
178+
Error messages should help the caller understand what went wrong and how to fix it:
179+
180+
```python
181+
# Good - specific and actionable
182+
raise ConnectError(Code.INVALID_ARGUMENT, "email must contain an @ symbol")
183+
184+
# Less helpful - too vague
185+
raise ConnectError(Code.INVALID_ARGUMENT, "invalid input")
186+
```
187+
188+
### Using error details for structured data
189+
190+
Rather than encoding structured information in error messages, use typed error details. For example:
191+
192+
```python
193+
from google.rpc.error_details_pb2 import BadRequest
194+
195+
# Good - structured details
196+
bad_request = BadRequest()
197+
for field, error in validation_errors.items():
198+
violation = bad_request.field_violations.add()
199+
violation.field = field
200+
violation.description = error
201+
raise ConnectError(Code.INVALID_ARGUMENT, "Validation failed", details=[bad_request])
202+
203+
# Less structured - information in message
204+
raise ConnectError(
205+
Code.INVALID_ARGUMENT,
206+
f"Validation failed: email: {email_error}, name: {name_error}"
207+
)
208+
```
209+
210+
**Note**: While error details provide structured error information, they require client-side deserialization to be fully useful for debugging. Make sure to document expected error detail types in your API documentation to help consumers properly handle them.
211+
212+
### Security considerations
213+
214+
Avoid including sensitive data in error messages or details that will be sent to clients. For example:
215+
216+
```python
217+
# Bad - leaks internal details
218+
raise ConnectError(Code.INTERNAL, f"Database query failed: {sql_query}")
219+
220+
# Good - generic message
221+
raise ConnectError(Code.INTERNAL, "Failed to complete request")
222+
```
223+
224+
### Handling timeouts
225+
226+
Client timeouts are represented with `Code.DEADLINE_EXCEEDED`:
227+
228+
```python
229+
from connectrpc.code import Code
230+
from connectrpc.errors import ConnectError
231+
232+
async with GreetServiceClient("http://localhost:8000") as client:
233+
try:
234+
response = await client.greet(GreetRequest(name="World"), timeout_ms=1000)
235+
except ConnectError as e:
236+
if e.code == Code.DEADLINE_EXCEEDED:
237+
print("Operation timed out")
238+
```
239+
240+
### Implementing retry logic
241+
242+
Some errors are retriable. Use appropriate error codes to signal this. Here's an example implementation:
243+
244+
```python
245+
import asyncio
246+
from connectrpc.code import Code
247+
from connectrpc.errors import ConnectError
248+
249+
async def call_with_retry(client, request, max_attempts=3):
250+
"""Retry logic for transient failures."""
251+
for attempt in range(max_attempts):
252+
try:
253+
return await client.greet(request)
254+
except ConnectError as e:
255+
# Only retry transient errors
256+
if e.code == Code.UNAVAILABLE and attempt < max_attempts - 1:
257+
await asyncio.sleep(2 ** attempt) # Exponential backoff
258+
continue
259+
raise
260+
```
261+
262+
### Error transformation in interceptors
263+
264+
Interceptors can catch and transform errors. This is useful for adding context, converting error types, or implementing retry logic. For example:
265+
266+
=== "ASGI"
267+
268+
```python
269+
from connectrpc.code import Code
270+
from connectrpc.errors import ConnectError
271+
272+
class ErrorLoggingInterceptor:
273+
async def intercept_unary(self, call_next, request, ctx):
274+
try:
275+
return await call_next(request, ctx)
276+
except ConnectError as e:
277+
# Log the error with context
278+
method = ctx.method()
279+
print(f"Error in {method.service_name}/{method.name}: {e.code} - {e.message}")
280+
# Re-raise the error
281+
raise
282+
except Exception as e:
283+
# Convert unexpected errors to ConnectError
284+
method = ctx.method()
285+
print(f"Unexpected error in {method.service_name}/{method.name}: {e}")
286+
raise ConnectError(Code.INTERNAL, "An unexpected error occurred")
287+
```
288+
289+
=== "WSGI"
290+
291+
```python
292+
from connectrpc.code import Code
293+
from connectrpc.errors import ConnectError
294+
295+
class ErrorLoggingInterceptor:
296+
def intercept_unary_sync(self, call_next, request, ctx):
297+
try:
298+
return call_next(request, ctx)
299+
except ConnectError as e:
300+
# Log the error with context
301+
method = ctx.method()
302+
print(f"Error in {method.service_name}/{method.name}: {e.code} - {e.message}")
303+
# Re-raise the error
304+
raise
305+
except Exception as e:
306+
# Convert unexpected errors to ConnectError
307+
method = ctx.method()
308+
print(f"Unexpected error in {method.service_name}/{method.name}: {e}")
309+
raise ConnectError(Code.INTERNAL, "An unexpected error occurred")
310+
```

0 commit comments

Comments
 (0)