diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..ebfe698 --- /dev/null +++ b/docs/errors.md @@ -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 diff --git a/docs/usage.md b/docs/usage.md index ed683b6..969c3a6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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]) + +# 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") + ```