-
Notifications
You must be signed in to change notification settings - Fork 3
Add doc on error handling #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
847bd95
a84aee0
04b9821
a7bd90b
851e31d
6252df7
6f9f072
efa629f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,192 @@ | ||
# Errors | ||
|
||
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. | ||
|
||
## Working with errors | ||
|
||
Connect handlers raise errors using `ConnectError`: | ||
|
||
=== "ASGI" | ||
|
||
```python | ||
from connectrpc.code import Code | ||
from connectrpc.errors import ConnectError | ||
from connectrpc.request import RequestContext | ||
|
||
async def greet(self, request: GreetRequest, ctx: RequestContext) -> GreetResponse: | ||
if not request.name: | ||
raise ConnectError(Code.INVALID_ARGUMENT, "name is required") | ||
return GreetResponse(greeting=f"Hello, {request.name}!") | ||
``` | ||
|
||
=== "WSGI" | ||
|
||
```python | ||
from connectrpc.code import Code | ||
from connectrpc.errors import ConnectError | ||
from connectrpc.request import RequestContext | ||
|
||
def greet(self, request: GreetRequest, ctx: RequestContext) -> GreetResponse: | ||
if not request.name: | ||
raise ConnectError(Code.INVALID_ARGUMENT, "name is required") | ||
return GreetResponse(greeting=f"Hello, {request.name}!") | ||
``` | ||
|
||
Clients catch errors the same way: | ||
|
||
=== "Async" | ||
|
||
```python | ||
from connectrpc.code import Code | ||
from connectrpc.errors import ConnectError | ||
|
||
async with GreetServiceClient("http://localhost:8000") as client: | ||
try: | ||
response = await client.greet(GreetRequest(name="")) | ||
except ConnectError as e: | ||
if e.code == Code.INVALID_ARGUMENT: | ||
print(f"Invalid request: {e.message}") | ||
else: | ||
print(f"RPC failed: {e.code} - {e.message}") | ||
``` | ||
|
||
=== "Sync" | ||
|
||
```python | ||
from connectrpc.code import Code | ||
from connectrpc.errors import ConnectError | ||
|
||
with GreetServiceClientSync("http://localhost:8000") as client: | ||
try: | ||
response = client.greet(GreetRequest(name="")) | ||
except ConnectError as e: | ||
if e.code == Code.INVALID_ARGUMENT: | ||
print(f"Invalid request: {e.message}") | ||
else: | ||
print(f"RPC failed: {e.code} - {e.message}") | ||
``` | ||
|
||
## Error codes | ||
|
||
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: | ||
|
||
```python | ||
from connectrpc.code import Code | ||
|
||
code = Code.INVALID_ARGUMENT | ||
code.value # "invalid_argument" | ||
|
||
# Access by name | ||
Code["INVALID_ARGUMENT"] # Code.INVALID_ARGUMENT | ||
``` | ||
|
||
## Error messages | ||
|
||
The `message` property contains a descriptive error message. In most cases, the message is provided by the backend implementing the service: | ||
|
||
```python | ||
try: | ||
response = await client.greet(GreetRequest(name="")) | ||
except ConnectError as e: | ||
print(e.message) # "name is required" | ||
``` | ||
|
||
## Error details | ||
|
||
Errors can include strongly-typed details using protobuf messages: | ||
|
||
```python | ||
from connectrpc.code import Code | ||
from connectrpc.errors import ConnectError | ||
from connectrpc.request import RequestContext | ||
from google.protobuf.struct_pb2 import Struct, Value | ||
|
||
async def create_user(self, request: CreateUserRequest, ctx: RequestContext) -> CreateUserResponse: | ||
if not request.email: | ||
error_detail = Struct(fields={ | ||
"field": Value(string_value="email"), | ||
"issue": Value(string_value="Email is required") | ||
}) | ||
|
||
raise ConnectError( | ||
Code.INVALID_ARGUMENT, | ||
"Invalid user request", | ||
details=[error_detail] | ||
) | ||
# ... rest of implementation | ||
``` | ||
|
||
### Reading error details on the client | ||
|
||
Error details are `google.protobuf.Any` messages that can be unpacked to their original types: | ||
|
||
```python | ||
try: | ||
response = await client.some_method(request) | ||
except ConnectError as e: | ||
for detail in e.details: | ||
# Check the type before unpacking | ||
if detail.Is(Struct.DESCRIPTOR): | ||
unpacked = Struct() | ||
detail.Unpack(unpacked) | ||
print(f"Error detail: {unpacked}") | ||
``` | ||
|
||
### Standard error detail types | ||
|
||
With `googleapis-common-protos` installed, you can use standard types like: | ||
|
||
- `BadRequest`: Field violations in a request | ||
- `RetryInfo`: When to retry | ||
- `Help`: Links to documentation | ||
- `QuotaFailure`: Quota violations | ||
- `ErrorInfo`: Structured error metadata | ||
|
||
Example: | ||
|
||
```python | ||
from google.rpc.error_details_pb2 import BadRequest | ||
|
||
bad_request = BadRequest() | ||
violation = bad_request.field_violations.add() | ||
violation.field = "email" | ||
violation.description = "Must be a valid email address" | ||
|
||
raise ConnectError( | ||
Code.INVALID_ARGUMENT, | ||
"Invalid email format", | ||
details=[bad_request] | ||
) | ||
``` | ||
|
||
## HTTP representation | ||
|
||
In the Connect protocol, errors are always JSON: | ||
|
||
```http | ||
HTTP/1.1 400 Bad Request | ||
Content-Type: application/json | ||
|
||
{ | ||
"code": "invalid_argument", | ||
"message": "name is required", | ||
"details": [ | ||
{ | ||
"type": "google.protobuf.Struct", | ||
"value": "base64-encoded-protobuf" | ||
} | ||
] | ||
} | ||
``` | ||
|
||
The `details` array contains error detail messages, where each entry has: | ||
|
||
- `type`: The fully-qualified protobuf message type (e.g., `google.protobuf.Struct`) | ||
- `value`: The protobuf message serialized in binary format and then base64-encoded | ||
|
||
## See also | ||
|
||
- [Interceptors](interceptors.md) for error transformation and logging | ||
- [Streaming](streaming.md) for stream-specific error handling | ||
- [Headers and trailers](headers-and-trailers.md) for attaching metadata to errors | ||
- [Usage guide](usage.md) for error handling best practices |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -159,3 +159,152 @@ class ElizaServiceImpl: | |
for message in request: | ||
yield eliza_pb2.ConverseResponse(sentence=f"You said: {message.sentence}") | ||
``` | ||
|
||
## Error Handling Best Practices | ||
|
||
### Choosing appropriate error codes | ||
|
||
Select error codes that accurately reflect the situation: | ||
|
||
- Use `INVALID_ARGUMENT` for malformed requests that should never be retried | ||
- Use `FAILED_PRECONDITION` for requests that might succeed if the system state changes | ||
- Use `UNAVAILABLE` for transient failures that should be retried | ||
- Use `INTERNAL` sparingly - it indicates a bug in your code | ||
|
||
For more detailed guidance on choosing error codes, see the [Connect protocol documentation](https://connectrpc.com/docs/protocol#error-codes). | ||
|
||
### Providing helpful error messages | ||
|
||
Error messages should help the caller understand what went wrong and how to fix it: | ||
|
||
```python | ||
# Good - specific and actionable | ||
raise ConnectError(Code.INVALID_ARGUMENT, "email must contain an @ symbol") | ||
|
||
# Less helpful - too vague | ||
raise ConnectError(Code.INVALID_ARGUMENT, "invalid input") | ||
``` | ||
|
||
### Using error details for structured data | ||
|
||
Rather than encoding structured information in error messages, use typed error details. For example: | ||
|
||
```python | ||
from google.rpc.error_details_pb2 import BadRequest | ||
|
||
# Good - structured details | ||
bad_request = BadRequest() | ||
for field, error in validation_errors.items(): | ||
violation = bad_request.field_violations.add() | ||
violation.field = field | ||
violation.description = error | ||
raise ConnectError(Code.INVALID_ARGUMENT, "Validation failed", details=[bad_request]) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. my only hesitation on recommending error details is that in practice, they can make it more difficult to debug if your client isn't already deserializing them. And there's no way to strongly enforce them in your API contract; the best you can really do is just document them. I think this is fine to leave as-is, just wish error details were easier to use in general. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this PR is trying to match the coverage of Go docs which similarly cover error details so is probably good as is. Agree that error details are a bit clunky but too useful for substituting for custom error codes to leave out.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see. I agree with both opinions, so I just added this https://github.com/connectrpc/connect-python/pull/19/files#diff-72376d0b487d7231cf31959bf266f6aea098e899743bae02e36d176cef4db476R210 |
||
|
||
# Less structured - information in message | ||
raise ConnectError( | ||
Code.INVALID_ARGUMENT, | ||
f"Validation failed: email: {email_error}, name: {name_error}" | ||
) | ||
``` | ||
|
||
**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. | ||
|
||
### Security considerations | ||
|
||
Avoid including sensitive data in error messages or details that will be sent to clients. For example: | ||
|
||
```python | ||
# Bad - leaks internal details | ||
raise ConnectError(Code.INTERNAL, f"Database query failed: {sql_query}") | ||
|
||
# Good - generic message | ||
raise ConnectError(Code.INTERNAL, "Failed to complete request") | ||
``` | ||
|
||
### Handling timeouts | ||
|
||
Client timeouts are represented with `Code.DEADLINE_EXCEEDED`: | ||
|
||
```python | ||
from connectrpc.code import Code | ||
from connectrpc.errors import ConnectError | ||
|
||
async with GreetServiceClient("http://localhost:8000") as client: | ||
try: | ||
response = await client.greet(GreetRequest(name="World"), timeout_ms=1000) | ||
except ConnectError as e: | ||
if e.code == Code.DEADLINE_EXCEEDED: | ||
print("Operation timed out") | ||
``` | ||
|
||
### Implementing retry logic | ||
|
||
Some errors are retriable. Use appropriate error codes to signal this. Here's an example implementation: | ||
|
||
```python | ||
import asyncio | ||
from connectrpc.code import Code | ||
from connectrpc.errors import ConnectError | ||
|
||
async def call_with_retry(client, request, max_attempts=3): | ||
"""Retry logic for transient failures.""" | ||
for attempt in range(max_attempts): | ||
try: | ||
return await client.greet(request) | ||
except ConnectError as e: | ||
# Only retry transient errors | ||
if e.code == Code.UNAVAILABLE and attempt < max_attempts - 1: | ||
await asyncio.sleep(2 ** attempt) # Exponential backoff | ||
continue | ||
raise | ||
``` | ||
|
||
### Error transformation in interceptors | ||
|
||
Interceptors can catch and transform errors. This is useful for adding context, converting error types, or implementing retry logic. For example: | ||
|
||
=== "ASGI" | ||
|
||
```python | ||
from connectrpc.code import Code | ||
from connectrpc.errors import ConnectError | ||
|
||
class ErrorLoggingInterceptor: | ||
async def intercept_unary(self, call_next, request, ctx): | ||
try: | ||
return await call_next(request, ctx) | ||
except ConnectError as e: | ||
# Log the error with context | ||
method = ctx.method() | ||
print(f"Error in {method.service_name}/{method.name}: {e.code} - {e.message}") | ||
# Re-raise the error | ||
raise | ||
except Exception as e: | ||
# Convert unexpected errors to ConnectError | ||
method = ctx.method() | ||
print(f"Unexpected error in {method.service_name}/{method.name}: {e}") | ||
raise ConnectError(Code.INTERNAL, "An unexpected error occurred") | ||
``` | ||
|
||
=== "WSGI" | ||
|
||
```python | ||
from connectrpc.code import Code | ||
from connectrpc.errors import ConnectError | ||
|
||
class ErrorLoggingInterceptor: | ||
def intercept_unary_sync(self, call_next, request, ctx): | ||
try: | ||
return call_next(request, ctx) | ||
except ConnectError as e: | ||
# Log the error with context | ||
method = ctx.method() | ||
print(f"Error in {method.service_name}/{method.name}: {e.code} - {e.message}") | ||
# Re-raise the error | ||
raise | ||
except Exception as e: | ||
# Convert unexpected errors to ConnectError | ||
method = ctx.method() | ||
print(f"Unexpected error in {method.service_name}/{method.name}: {e}") | ||
raise ConnectError(Code.INTERNAL, "An unexpected error occurred") | ||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might link to https://connectrpc.com/docs/protocol#error-codes; the discussion below the table about how to choose an error code is pretty useful.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, thanks! I just wrote the link below.