Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions docs/errors.md
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
149 changes: 149 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +167 to +172
Copy link
Member

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.

Copy link
Contributor Author

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.


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])
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

@i2y i2y Oct 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


# 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")
```