From 847bd95d9b4be8b2f9787a35442520bdc5c30e0c Mon Sep 17 00:00:00 2001 From: i2y <6240399+i2y@users.noreply.github.com> Date: Sat, 4 Oct 2025 10:28:30 +0900 Subject: [PATCH 1/6] Add doc on error handling Signed-off-by: i2y <6240399+i2y@users.noreply.github.com> --- docs/errors.md | 393 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 docs/errors.md diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..a40a81e --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,393 @@ +# Error Handling + +Connect uses a standard set of error codes to indicate what went wrong with an RPC. Each error includes a `Code`, a human-readable message, and optionally some typed error details. This document explains how to work with errors in connect-python. + +## Error codes + +Connect defines 16 error codes. Each code maps to a specific HTTP status code when using the Connect protocol over HTTP. The full list of codes is available in the `connectrpc.code.Code` enum: + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `CANCELED` | 499 | RPC canceled, usually by the caller | +| `UNKNOWN` | 500 | Catch-all for errors of unclear origin | +| `INVALID_ARGUMENT` | 400 | Request is invalid, regardless of system state | +| `DEADLINE_EXCEEDED` | 504 | Deadline expired before RPC could complete | +| `NOT_FOUND` | 404 | Requested resource can't be found | +| `ALREADY_EXISTS` | 409 | Caller attempted to create a resource that already exists | +| `PERMISSION_DENIED` | 403 | Caller isn't authorized to perform the operation | +| `RESOURCE_EXHAUSTED` | 429 | Operation can't be completed because some resource is exhausted | +| `FAILED_PRECONDITION` | 400 | Operation can't be completed because system isn't in required state | +| `ABORTED` | 409 | Operation was aborted, often due to concurrency issues | +| `OUT_OF_RANGE` | 400 | Operation was attempted past the valid range | +| `UNIMPLEMENTED` | 501 | Operation isn't implemented, supported, or enabled | +| `INTERNAL` | 500 | An invariant expected by the system has been broken | +| `UNAVAILABLE` | 503 | Service is currently unavailable, usually transiently | +| `DATA_LOSS` | 500 | Unrecoverable data loss or corruption | +| `UNAUTHENTICATED` | 401 | Caller doesn't have valid authentication credentials | + +## Raising errors in servers + +To return an error from a server handler, raise a `ConnectError`: + +=== "ASGI" + + ```python + from connectrpc.code import Code + from connectrpc.errors import ConnectError + from connectrpc.request import RequestContext + from greet.v1.greet_pb2 import GreetRequest, GreetResponse + + class GreetService: + 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 + from greet.v1.greet_pb2 import GreetRequest, GreetResponse + + class GreetServiceSync: + 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}!") + ``` + +Any `ConnectError` raised in a handler will be serialized and sent to the client. If you raise any other exception type, it will be converted to a `ConnectError` with code `INTERNAL` and a generic error message. The original exception details will be logged but not sent to the client to avoid leaking sensitive information. + +## Handling errors in clients + +When a client receives an error response, the client stub raises a `ConnectError`: + +=== "Async" + + ```python + from connectrpc.code import Code + from connectrpc.errors import ConnectError + from greet.v1.greet_connect import GreetServiceClient + from greet.v1.greet_pb2 import GreetRequest + + async def main(): + client = GreetServiceClient("http://localhost:8000") + 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 + from greet.v1.greet_connect import GreetServiceClientSync + from greet.v1.greet_pb2 import GreetRequest + + def main(): + client = GreetServiceClientSync("http://localhost:8000") + 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}") + ``` + +Client-side errors (like network failures, timeouts, or protocol violations) are also raised as `ConnectError` instances with appropriate error codes. + +## Error details + +In addition to a code and message, errors can include typed details. This is useful for providing structured information about validation errors, rate limiting, retry policies, and more. + +### Adding details to errors + +To add details to an error, pass protobuf messages to the `details` parameter: + +```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: + # Create structured error details using Struct + 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 +``` + +You can include multiple detail messages of different types: + +```python +from google.protobuf.struct_pb2 import Struct, Value + +validation_errors = Struct(fields={ + "email": Value(string_value="Must be a valid email address"), + "age": Value(string_value="Must be at least 18") +}) + +help_info = Struct(fields={ + "documentation": Value(string_value="https://docs.example.com/validation") +}) + +raise ConnectError( + Code.INVALID_ARGUMENT, + "Validation failed", + details=[validation_errors, help_info] +) +``` + +If you have the `googleapis-common-protos` package installed, you can use the standard error detail types like `BadRequest`: + +```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] +) +``` + +### Reading error details + +On the client side, error details are available through the `details` property. Details are stored as `google.protobuf.Any` messages and can be unpacked to their original types: + +```python +from google.protobuf.struct_pb2 import Struct + +try: + response = await client.some_method(request) +except ConnectError as e: + for detail in e.details: + # Unpack the detail to the expected type + unpacked = Struct() + if detail.Unpack(unpacked): + # Successfully unpacked + print(f"Error detail: {unpacked}") +``` + +You can also check the type before unpacking using the `.Is()` method: + +```python +from google.protobuf.struct_pb2 import Struct + +try: + response = await client.some_method(request) +except ConnectError as e: + for detail in e.details: + if detail.Is(Struct.DESCRIPTOR): + unpacked = Struct() + detail.Unpack(unpacked) + print(f"Struct detail: {unpacked}") +``` + +### Common error detail types + +You can use any protobuf message type for error details. Some commonly used types include: + +**Built-in types** (available in `google.protobuf`): +- `Struct`: Generic structured data +- `Any`: Wrap arbitrary protobuf messages +- `Duration`: Time durations +- `Timestamp`: Specific points in time + +**Standard error details** (requires `googleapis-common-protos` package): + +Google provides standard error detail types in the `google.rpc.error_details_pb2` module: + +- `BadRequest`: Describes violations in a client request +- `Help`: Provides links to documentation or related resources +- `RetryInfo`: Tells the client when to retry +- `QuotaFailure`: Describes quota violations +- `PreconditionFailure`: Describes failed preconditions +- `ErrorInfo`: Provides structured error metadata with a reason, domain, and metadata + +To use these types, install the package: + +```bash +pip install googleapis-common-protos +``` + +Example with `RetryInfo`: + +```python +from google.protobuf.duration_pb2 import Duration +from google.rpc.error_details_pb2 import RetryInfo + +retry_info = RetryInfo() +retry_info.retry_delay.CopyFrom(Duration(seconds=30)) + +raise ConnectError( + Code.RESOURCE_EXHAUSTED, + "Rate limit exceeded", + details=[retry_info] +) +``` + +## Error handling in interceptors + +Interceptors can catch and transform errors. This is useful for adding context, converting error types, or implementing retry logic: + +=== "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 + print(f"Error in {ctx.method()}: {e.code} - {e.message}") + # Re-raise the error + raise + except Exception as e: + # Convert unexpected errors to ConnectError + print(f"Unexpected error in {ctx.method()}: {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 + print(f"Error in {ctx.method()}: {e.code} - {e.message}") + # Re-raise the error + raise + except Exception as e: + # Convert unexpected errors to ConnectError + print(f"Unexpected error in {ctx.method()}: {e}") + raise ConnectError(Code.INTERNAL, "An unexpected error occurred") + ``` + +## Best practices + +### Choose 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 + +### Provide helpful messages + +Error messages should help the caller understand what went wrong and how to fix it: + +```python +# Good +raise ConnectError(Code.INVALID_ARGUMENT, "email must contain an @ symbol") + +# Less helpful +raise ConnectError(Code.INVALID_ARGUMENT, "invalid input") +``` + +### Use error details for structured data + +Rather than encoding structured information in error messages, use typed error details: + +```python +# 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}" +) +``` + +### Don't leak sensitive information + +Avoid including sensitive data in error messages or details that will be sent to clients: + +```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") +``` + +### Handle timeouts appropriately + +Client timeouts are represented with `Code.DEADLINE_EXCEEDED`: + +```python +from connectrpc.code import Code +from connectrpc.errors import ConnectError +from greet.v1.greet_connect import GreetServiceClient +from greet.v1.greet_pb2 import GreetRequest + +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") +``` + +### Consider retry logic + +Some errors are retriable. Use appropriate error codes to signal this: + +```python +import asyncio +from connectrpc.code import Code +from connectrpc.errors import ConnectError +from greet.v1.greet_connect import GreetServiceClient +from greet.v1.greet_pb2 import GreetRequest + +async def call_with_retry(client: GreetServiceClient, request: GreetRequest, max_attempts: int = 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 +``` From a84aee07297deb61d29eba8aab5ef552f06ace30 Mon Sep 17 00:00:00 2001 From: i2y <6240399+i2y@users.noreply.github.com> Date: Sat, 4 Oct 2025 11:07:16 +0900 Subject: [PATCH 2/6] fix Signed-off-by: i2y <6240399+i2y@users.noreply.github.com> --- docs/errors.md | 74 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index a40a81e..3d7de32 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -2,6 +2,8 @@ Connect uses a standard set of error codes to indicate what went wrong with an RPC. Each error includes a `Code`, a human-readable message, and optionally some typed error details. This document explains how to work with errors in connect-python. +For streaming RPCs, error handling has some special considerations - see the [streaming documentation](streaming.md) for details. You can also attach error information to headers and trailers - see the [headers and trailers documentation](headers-and-trailers.md). + ## Error codes Connect defines 16 error codes. Each code maps to a specific HTTP status code when using the Connect protocol over HTTP. The full list of codes is available in the `connectrpc.code.Code` enum: @@ -74,14 +76,14 @@ When a client receives an error response, the client stub raises a `ConnectError from greet.v1.greet_pb2 import GreetRequest async def main(): - client = GreetServiceClient("http://localhost:8000") - 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}") + 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" @@ -93,14 +95,14 @@ When a client receives an error response, the client stub raises a `ConnectError from greet.v1.greet_pb2 import GreetRequest def main(): - client = GreetServiceClientSync("http://localhost:8000") - 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}") + 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}") ``` Client-side errors (like network failures, timeouts, or protocol violations) are also raised as `ConnectError` instances with appropriate error codes. @@ -206,6 +208,44 @@ except ConnectError as e: print(f"Struct detail: {unpacked}") ``` +#### Understanding google.protobuf.Any + +The `google.protobuf.Any` type stores arbitrary protobuf messages along with their type information. Key properties: + +- `type_url`: A string identifying the message type (e.g., `type.googleapis.com/google.protobuf.Struct`) +- `value`: The serialized bytes of the actual message + +Debugging tips: + +```python +try: + response = await client.some_method(request) +except ConnectError as e: + for detail in e.details: + # Inspect the type URL to understand what type this detail is + print(f"Detail type: {detail.type_url}") + + # Try unpacking with the expected type + if "Struct" in detail.type_url: + unpacked = Struct() + if detail.Unpack(unpacked): + print(f"Struct content: {unpacked}") + elif "BadRequest" in detail.type_url: + from google.rpc.error_details_pb2 import BadRequest + unpacked = BadRequest() + if detail.Unpack(unpacked): + for violation in unpacked.field_violations: + print(f"Field: {violation.field}, Error: {violation.description}") +``` + +Common issues and solutions: + +1. **Type mismatch**: If `Unpack()` returns `False`, the message type doesn't match. Check the `type_url` to see the actual type. + +2. **Missing proto imports**: Ensure you've imported the correct protobuf message classes for the error details you expect to receive. + +3. **Custom error details**: If using custom protobuf messages for error details, ensure both client and server have access to the same proto definitions. + ### Common error detail types You can use any protobuf message type for error details. Some commonly used types include: @@ -251,7 +291,7 @@ raise ConnectError( ## Error handling in interceptors -Interceptors can catch and transform errors. This is useful for adding context, converting error types, or implementing retry logic: +Interceptors can catch and transform errors. This is useful for adding context, converting error types, or implementing retry logic. For more details on interceptors, see the [interceptors documentation](interceptors.md): === "ASGI" From 04b982185819bc934618f6168ea0b4d577d32070 Mon Sep 17 00:00:00 2001 From: i2y <6240399+i2y@users.noreply.github.com> Date: Sat, 4 Oct 2025 12:07:38 +0900 Subject: [PATCH 3/6] fix Signed-off-by: i2y <6240399+i2y@users.noreply.github.com> --- docs/errors.md | 437 +++++++++++-------------------------------------- docs/usage.md | 141 ++++++++++++++++ 2 files changed, 241 insertions(+), 337 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index 3d7de32..c6bb731 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -1,129 +1,91 @@ -# Error Handling +# Errors -Connect uses a standard set of error codes to indicate what went wrong with an RPC. Each error includes a `Code`, a human-readable message, and optionally some typed error details. This document explains how to work with errors in connect-python. +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. -For streaming RPCs, error handling has some special considerations - see the [streaming documentation](streaming.md) for details. You can also attach error information to headers and trailers - see the [headers and trailers documentation](headers-and-trailers.md). +## Working with errors -## Error codes - -Connect defines 16 error codes. Each code maps to a specific HTTP status code when using the Connect protocol over HTTP. The full list of codes is available in the `connectrpc.code.Code` enum: - -| Code | HTTP Status | Description | -|------|-------------|-------------| -| `CANCELED` | 499 | RPC canceled, usually by the caller | -| `UNKNOWN` | 500 | Catch-all for errors of unclear origin | -| `INVALID_ARGUMENT` | 400 | Request is invalid, regardless of system state | -| `DEADLINE_EXCEEDED` | 504 | Deadline expired before RPC could complete | -| `NOT_FOUND` | 404 | Requested resource can't be found | -| `ALREADY_EXISTS` | 409 | Caller attempted to create a resource that already exists | -| `PERMISSION_DENIED` | 403 | Caller isn't authorized to perform the operation | -| `RESOURCE_EXHAUSTED` | 429 | Operation can't be completed because some resource is exhausted | -| `FAILED_PRECONDITION` | 400 | Operation can't be completed because system isn't in required state | -| `ABORTED` | 409 | Operation was aborted, often due to concurrency issues | -| `OUT_OF_RANGE` | 400 | Operation was attempted past the valid range | -| `UNIMPLEMENTED` | 501 | Operation isn't implemented, supported, or enabled | -| `INTERNAL` | 500 | An invariant expected by the system has been broken | -| `UNAVAILABLE` | 503 | Service is currently unavailable, usually transiently | -| `DATA_LOSS` | 500 | Unrecoverable data loss or corruption | -| `UNAUTHENTICATED` | 401 | Caller doesn't have valid authentication credentials | - -## Raising errors in servers - -To return an error from a server handler, raise a `ConnectError`: +Connect handlers raise errors using `ConnectError`: === "ASGI" ```python - from connectrpc.code import Code - from connectrpc.errors import ConnectError - from connectrpc.request import RequestContext - from greet.v1.greet_pb2 import GreetRequest, GreetResponse - - class GreetService: - 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}!") + 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 - from greet.v1.greet_pb2 import GreetRequest, GreetResponse - - class GreetServiceSync: - 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}!") + 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}!") ``` -Any `ConnectError` raised in a handler will be serialized and sent to the client. If you raise any other exception type, it will be converted to a `ConnectError` with code `INTERNAL` and a generic error message. The original exception details will be logged but not sent to the client to avoid leaking sensitive information. - -## Handling errors in clients - -When a client receives an error response, the client stub raises a `ConnectError`: +Clients catch errors the same way: === "Async" ```python - from connectrpc.code import Code - from connectrpc.errors import ConnectError - from greet.v1.greet_connect import GreetServiceClient - from greet.v1.greet_pb2 import GreetRequest - - async def main(): - 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}") + 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 - from greet.v1.greet_connect import GreetServiceClientSync - from greet.v1.greet_pb2 import GreetRequest - - def main(): - 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}") + 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}") ``` -Client-side errors (like network failures, timeouts, or protocol violations) are also raised as `ConnectError` instances with appropriate error codes. +## Error codes -## Error details +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" -In addition to a code and message, errors can include typed details. This is useful for providing structured information about validation errors, rate limiting, retry policies, and more. +# Access by name +Code["INVALID_ARGUMENT"] # Code.INVALID_ARGUMENT +``` -### Adding details to errors +## Error messages -To add details to an error, pass protobuf messages to the `details` parameter: +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: - # Create structured error details using Struct error_detail = Struct(fields={ "field": Value(string_value="email"), "issue": Value(string_value="Email is required") @@ -134,83 +96,24 @@ async def create_user(self, request: CreateUserRequest, ctx: RequestContext) -> "Invalid user request", details=[error_detail] ) - # ... rest of implementation -``` - -You can include multiple detail messages of different types: - -```python -from google.protobuf.struct_pb2 import Struct, Value - -validation_errors = Struct(fields={ - "email": Value(string_value="Must be a valid email address"), - "age": Value(string_value="Must be at least 18") -}) - -help_info = Struct(fields={ - "documentation": Value(string_value="https://docs.example.com/validation") -}) - -raise ConnectError( - Code.INVALID_ARGUMENT, - "Validation failed", - details=[validation_errors, help_info] -) -``` - -If you have the `googleapis-common-protos` package installed, you can use the standard error detail types like `BadRequest`: - -```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] -) ``` -### Reading error details - -On the client side, error details are available through the `details` property. Details are stored as `google.protobuf.Any` messages and can be unpacked to their original types: +Reading error details on the client: ```python -from google.protobuf.struct_pb2 import Struct - try: response = await client.some_method(request) except ConnectError as e: for detail in e.details: - # Unpack the detail to the expected type + # Details are google.protobuf.Any messages unpacked = Struct() if detail.Unpack(unpacked): - # Successfully unpacked print(f"Error detail: {unpacked}") ``` -You can also check the type before unpacking using the `.Is()` method: - -```python -from google.protobuf.struct_pb2 import Struct +### Understanding google.protobuf.Any -try: - response = await client.some_method(request) -except ConnectError as e: - for detail in e.details: - if detail.Is(Struct.DESCRIPTOR): - unpacked = Struct() - detail.Unpack(unpacked) - print(f"Struct detail: {unpacked}") -``` - -#### Understanding google.protobuf.Any - -The `google.protobuf.Any` type stores arbitrary protobuf messages along with their type information. Key properties: +The `google.protobuf.Any` type stores arbitrary protobuf messages along with their type information: - `type_url`: A string identifying the message type (e.g., `type.googleapis.com/google.protobuf.Struct`) - `value`: The serialized bytes of the actual message @@ -222,212 +125,72 @@ try: response = await client.some_method(request) except ConnectError as e: for detail in e.details: - # Inspect the type URL to understand what type this detail is + # Inspect the type URL print(f"Detail type: {detail.type_url}") - # Try unpacking with the expected type - if "Struct" in detail.type_url: + # Check type before unpacking + if detail.Is(Struct.DESCRIPTOR): unpacked = Struct() - if detail.Unpack(unpacked): - print(f"Struct content: {unpacked}") - elif "BadRequest" in detail.type_url: - from google.rpc.error_details_pb2 import BadRequest - unpacked = BadRequest() - if detail.Unpack(unpacked): - for violation in unpacked.field_violations: - print(f"Field: {violation.field}, Error: {violation.description}") + detail.Unpack(unpacked) + print(f"Struct detail: {unpacked}") ``` -Common issues and solutions: - -1. **Type mismatch**: If `Unpack()` returns `False`, the message type doesn't match. Check the `type_url` to see the actual type. - -2. **Missing proto imports**: Ensure you've imported the correct protobuf message classes for the error details you expect to receive. - -3. **Custom error details**: If using custom protobuf messages for error details, ensure both client and server have access to the same proto definitions. +Common issues: -### Common error detail types +1. **Type mismatch**: If `Unpack()` returns `False`, check the `type_url` to see the actual type +2. **Missing imports**: Import the protobuf message classes for the error details you expect +3. **Custom details**: Ensure both client and server have access to the same proto definitions -You can use any protobuf message type for error details. Some commonly used types include: +### Standard error detail types -**Built-in types** (available in `google.protobuf`): -- `Struct`: Generic structured data -- `Any`: Wrap arbitrary protobuf messages -- `Duration`: Time durations -- `Timestamp`: Specific points in time +With `googleapis-common-protos` installed, you can use standard types like: -**Standard error details** (requires `googleapis-common-protos` package): +- `BadRequest`: Field violations in a request +- `RetryInfo`: When to retry +- `Help`: Links to documentation +- `QuotaFailure`: Quota violations +- `ErrorInfo`: Structured error metadata -Google provides standard error detail types in the `google.rpc.error_details_pb2` module: - -- `BadRequest`: Describes violations in a client request -- `Help`: Provides links to documentation or related resources -- `RetryInfo`: Tells the client when to retry -- `QuotaFailure`: Describes quota violations -- `PreconditionFailure`: Describes failed preconditions -- `ErrorInfo`: Provides structured error metadata with a reason, domain, and metadata - -To use these types, install the package: - -```bash -pip install googleapis-common-protos -``` - -Example with `RetryInfo`: +Example: ```python -from google.protobuf.duration_pb2 import Duration -from google.rpc.error_details_pb2 import RetryInfo - -retry_info = RetryInfo() -retry_info.retry_delay.CopyFrom(Duration(seconds=30)) - -raise ConnectError( - Code.RESOURCE_EXHAUSTED, - "Rate limit exceeded", - details=[retry_info] -) -``` - -## Error handling in interceptors - -Interceptors can catch and transform errors. This is useful for adding context, converting error types, or implementing retry logic. For more details on interceptors, see the [interceptors documentation](interceptors.md): - -=== "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 - print(f"Error in {ctx.method()}: {e.code} - {e.message}") - # Re-raise the error - raise - except Exception as e: - # Convert unexpected errors to ConnectError - print(f"Unexpected error in {ctx.method()}: {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 - print(f"Error in {ctx.method()}: {e.code} - {e.message}") - # Re-raise the error - raise - except Exception as e: - # Convert unexpected errors to ConnectError - print(f"Unexpected error in {ctx.method()}: {e}") - raise ConnectError(Code.INTERNAL, "An unexpected error occurred") - ``` - -## Best practices - -### Choose 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 - -### Provide helpful messages - -Error messages should help the caller understand what went wrong and how to fix it: - -```python -# Good -raise ConnectError(Code.INVALID_ARGUMENT, "email must contain an @ symbol") - -# Less helpful -raise ConnectError(Code.INVALID_ARGUMENT, "invalid input") -``` - -### Use error details for structured data - -Rather than encoding structured information in error messages, use typed error details: +from google.rpc.error_details_pb2 import BadRequest -```python -# 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]) +violation = bad_request.field_violations.add() +violation.field = "email" +violation.description = "Must be a valid email address" -# Less structured - information in message raise ConnectError( Code.INVALID_ARGUMENT, - f"Validation failed: email: {email_error}, name: {name_error}" + "Invalid email format", + details=[bad_request] ) ``` -### Don't leak sensitive information - -Avoid including sensitive data in error messages or details that will be sent to clients: - -```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") -``` +## HTTP representation -### Handle timeouts appropriately +In the Connect protocol, errors are always JSON: -Client timeouts are represented with `Code.DEADLINE_EXCEEDED`: +```json +HTTP/1.1 400 Bad Request +Content-Type: application/json -```python -from connectrpc.code import Code -from connectrpc.errors import ConnectError -from greet.v1.greet_connect import GreetServiceClient -from greet.v1.greet_pb2 import GreetRequest - -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") +{ + "code": "invalid_argument", + "message": "name is required", + "details": [ + { + "type": "google.protobuf.Struct", + "value": "base64-encoded-protobuf" + } + ] +} ``` -### Consider retry logic +## See also -Some errors are retriable. Use appropriate error codes to signal this: - -```python -import asyncio -from connectrpc.code import Code -from connectrpc.errors import ConnectError -from greet.v1.greet_connect import GreetServiceClient -from greet.v1.greet_pb2 import GreetRequest - -async def call_with_retry(client: GreetServiceClient, request: GreetRequest, max_attempts: int = 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 -``` +- [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 6eaaed5..f9e8955 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -159,3 +159,144 @@ class ElizaServiceImpl: for msg in req: yield eliza_pb2.ConverseResponse(sentence=f"You said: {msg.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 + +### 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}" +) +``` + +### 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 + print(f"Error in {ctx.method()}: {e.code} - {e.message}") + # Re-raise the error + raise + except Exception as e: + # Convert unexpected errors to ConnectError + print(f"Unexpected error in {ctx.method()}: {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 + print(f"Error in {ctx.method()}: {e.code} - {e.message}") + # Re-raise the error + raise + except Exception as e: + # Convert unexpected errors to ConnectError + print(f"Unexpected error in {ctx.method()}: {e}") + raise ConnectError(Code.INTERNAL, "An unexpected error occurred") + ``` From a7bd90b2e953d8cb526a11458517fa2ce5f09738 Mon Sep 17 00:00:00 2001 From: i2y <6240399+i2y@users.noreply.github.com> Date: Sat, 4 Oct 2025 12:10:40 +0900 Subject: [PATCH 4/6] fix Signed-off-by: i2y <6240399+i2y@users.noreply.github.com> --- docs/errors.md | 35 +++-------------------------------- docs/usage.md | 12 ++++++++---- 2 files changed, 11 insertions(+), 36 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index c6bb731..af6b32a 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -98,49 +98,20 @@ async def create_user(self, request: CreateUserRequest, ctx: RequestContext) -> ) ``` -Reading error details on the client: +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: - # Details are google.protobuf.Any messages - unpacked = Struct() - if detail.Unpack(unpacked): - print(f"Error detail: {unpacked}") -``` - -### Understanding google.protobuf.Any - -The `google.protobuf.Any` type stores arbitrary protobuf messages along with their type information: - -- `type_url`: A string identifying the message type (e.g., `type.googleapis.com/google.protobuf.Struct`) -- `value`: The serialized bytes of the actual message - -Debugging tips: - -```python -try: - response = await client.some_method(request) -except ConnectError as e: - for detail in e.details: - # Inspect the type URL - print(f"Detail type: {detail.type_url}") - - # Check type before unpacking + # Check the type before unpacking if detail.Is(Struct.DESCRIPTOR): unpacked = Struct() detail.Unpack(unpacked) - print(f"Struct detail: {unpacked}") + print(f"Error detail: {unpacked}") ``` -Common issues: - -1. **Type mismatch**: If `Unpack()` returns `False`, check the `type_url` to see the actual type -2. **Missing imports**: Import the protobuf message classes for the error details you expect -3. **Custom details**: Ensure both client and server have access to the same proto definitions - ### Standard error detail types With `googleapis-common-protos` installed, you can use standard types like: diff --git a/docs/usage.md b/docs/usage.md index f9e8955..b082c59 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -271,12 +271,14 @@ Interceptors can catch and transform errors. This is useful for adding context, return await call_next(request, ctx) except ConnectError as e: # Log the error with context - print(f"Error in {ctx.method()}: {e.code} - {e.message}") + 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 - print(f"Unexpected error in {ctx.method()}: {e}") + method = ctx.method() + print(f"Unexpected error in {method.service_name}/{method.name}: {e}") raise ConnectError(Code.INTERNAL, "An unexpected error occurred") ``` @@ -292,11 +294,13 @@ Interceptors can catch and transform errors. This is useful for adding context, return call_next(request, ctx) except ConnectError as e: # Log the error with context - print(f"Error in {ctx.method()}: {e.code} - {e.message}") + 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 - print(f"Unexpected error in {ctx.method()}: {e}") + method = ctx.method() + print(f"Unexpected error in {method.service_name}/{method.name}: {e}") raise ConnectError(Code.INTERNAL, "An unexpected error occurred") ``` From 851e31d1f15c5b86e99715bd4bb064e01858180d Mon Sep 17 00:00:00 2001 From: i2y <6240399+i2y@users.noreply.github.com> Date: Sat, 4 Oct 2025 12:31:04 +0900 Subject: [PATCH 5/6] fix Signed-off-by: i2y <6240399+i2y@users.noreply.github.com> --- docs/errors.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/errors.md b/docs/errors.md index af6b32a..ed8f4a9 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -9,6 +9,10 @@ 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") @@ -18,6 +22,10 @@ Connect handlers raise errors using `ConnectError`: === "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") @@ -29,6 +37,9 @@ 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="")) @@ -42,6 +53,9 @@ Clients catch errors the same way: === "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="")) @@ -82,6 +96,9 @@ except ConnectError as e: 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: @@ -96,9 +113,12 @@ async def create_user(self, request: CreateUserRequest, ctx: RequestContext) -> "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: +### Reading error details on the client + +Error details are `google.protobuf.Any` messages that can be unpacked to their original types: ```python try: @@ -159,6 +179,11 @@ Content-Type: application/json } ``` +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 From 6f9f0727bc50984150694544bfc921e07487f7c7 Mon Sep 17 00:00:00 2001 From: i2y <6240399+i2y@users.noreply.github.com> Date: Sun, 5 Oct 2025 18:42:48 +0900 Subject: [PATCH 6/6] address review feedback Signed-off-by: i2y <6240399+i2y@users.noreply.github.com> --- docs/errors.md | 2 +- docs/usage.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/errors.md b/docs/errors.md index ed8f4a9..ebfe698 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -163,7 +163,7 @@ raise ConnectError( In the Connect protocol, errors are always JSON: -```json +```http HTTP/1.1 400 Bad Request Content-Type: application/json diff --git a/docs/usage.md b/docs/usage.md index b082c59..ec4b2f3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -171,6 +171,8 @@ Select error codes that accurately reflect the situation: - 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: @@ -205,6 +207,8 @@ raise ConnectError( ) ``` +**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: