Rust Error Handling Strategy
Context
Rust proxy implementation requires consistent error handling that:
- Preserves error context through call chains
- Enables debugging without verbose logging
- Leverages Rust's type system for compile-time safety
- Integrates with async/await
- Provides structured error information for observability
Decision
Adopt modern Rust error handling with thiserror
and anyhow
:
- Use
thiserror
for library code (typed errors with context) - Use
anyhow
for application code (error propagation with context) - Implement
From
traits for error conversion - Use
?
operator for error propagation - Add context with
.context()
at each layer - Define domain-specific error types per module
Rationale
Why thiserror + anyhow
thiserror for library/domain errors:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum BackendError {
#[error("backend unavailable: {0}")]
Unavailable(String),
#[error("namespace not found: {namespace}")]
NamespaceNotFound { namespace: String },
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
}
anyhow for application/handler errors:
use anyhow::{Context, Result};
async fn handle_put_request(req: PutRequest) -> Result<PutResponse> {
let backend = get_backend(&req.namespace)
.await
.context(format!("failed to get backend for namespace: {}", req.namespace))?;
backend
.put(req.items)
.await
.context("failed to put items")?;
Ok(PutResponse { success: true })
}
Benefits:
- Compile-time error type checking (thiserror)
- Ergonomic error propagation (anyhow)
- Rich error context without manual wrapping
- Stack traces in debug builds
- Structured error information
Error Conversion Pattern
// Domain error type
#[derive(Error, Debug)]
pub enum KeyValueError {
#[error("item not found: {key}")]
NotFound { key: String },
#[error("backend error: {0}")]
Backend(#[from] BackendError),
}
// Automatic conversion via From trait
impl From<sqlx::Error> for KeyValueError {
fn from(e: sqlx::Error) -> Self {
Self::Backend(BackendError::Database(e))
}
}
// Usage
async fn get_item(key: &str) -> Result<Item, KeyValueError> {
let row = sqlx::query_as("SELECT * FROM items WHERE key = ?")
.bind(key)
.fetch_one(&pool)
.await?; // Automatic conversion via From
Ok(row)
}
Alternatives Considered
-
Manual error wrapping
- Pros: No dependencies
- Cons: Verbose, error-prone, no stack traces
- Rejected: Too much boilerplate
-
eyre
instead ofanyhow
- Pros: Customizable reports, similar API
- Cons: Smaller ecosystem, less battle-tested
- Rejected: anyhow more widely adopted
-
snafu
instead ofthiserror
- Pros: Context selectors, different API
- Cons: More complex, steeper learning curve
- Rejected: thiserror simpler and more idiomatic
Consequences
Positive
- Type-safe error handling with thiserror
- Ergonomic error propagation with
?
operator - Rich error context for debugging
- Stack traces in development
- Structured errors for observability
- Compile-time guarantees
Negative
- Two dependencies (but they work together seamlessly)
- Must decide when to use thiserror vs anyhow
- Error types require upfront design
Neutral
- Error handling is explicit (Rust's philosophy)
- Need to define error types per module
Implementation Notes
Module Structure
proxy/src/ ├── error.rs # Top-level error types ├── backend/ │ ├── mod.rs │ └── error.rs # Backend-specific errors ├── keyvalue/ │ ├── mod.rs │ └── error.rs # KeyValue-specific errors └── main.rs
### Top-Level Error Types
// proxy/src/error.rs use thiserror::Error;
#[derive(Error, Debug)] pub enum ProxyError { #[error("configuration error: {0}")] Config(String),
#[error("backend error: {0}")]
Backend(#[from] crate::backend::BackendError),
#[error("keyvalue error: {0}")]
KeyValue(#[from] crate::keyvalue::KeyValueError),
#[error("gRPC error: {0}")]
Grpc(#[from] tonic::Status),
}
// Convert to gRPC Status for responses
impl From
### Handler Pattern
use anyhow::{Context, Result}; use tonic::{Request, Response, Status};
#[tonic::async_trait]
impl KeyValueService for KeyValueHandler {
async fn put(
&self,
request: Request
// Use anyhow::Result internally
let result: Result<PutResponse> = async {
let backend = self
.get_backend(&req.namespace)
.await
.context(format!("namespace: {}", req.namespace))?;
backend
.put(&req.id, req.items)
.await
.context("backend put operation")?;
Ok(PutResponse { success: true })
}
.await;
// Convert to gRPC Status
match result {
Ok(resp) => Ok(Response::new(resp)),
Err(e) => {
tracing::error!("put request failed: {:?}", e);
Err(Status::internal(e.to_string()))
}
}
}
}
### Backend Error Definition
// proxy/src/backend/error.rs use thiserror::Error;
#[derive(Error, Debug)] pub enum BackendError { #[error("connection failed: {endpoint}")] ConnectionFailed { endpoint: String },
#[error("timeout after {timeout_ms}ms")]
Timeout { timeout_ms: u64 },
#[error("namespace not found: {namespace}")]
NamespaceNotFound { namespace: String },
#[error("sqlx error: {0}")]
Sqlx(#[from] sqlx::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
### Testing Error Conditions
#[cfg(test)] mod tests { use super::*;
#[tokio::test]
async fn test_get_nonexistent_namespace() {
let handler = KeyValueHandler::new();
let req = Request::new(GetRequest {
namespace: "nonexistent".to_string(),
id: "123".to_string(),
predicate: None,
});
let result = handler.get(req).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code(), tonic::Code::NotFound);
}
#[tokio::test]
async fn test_backend_error_conversion() {
let backend_err = BackendError::NamespaceNotFound {
namespace: "test".to_string(),
};
let proxy_err: ProxyError = backend_err.into();
let status: tonic::Status = proxy_err.into();
assert_eq!(status.code(), tonic::Code::NotFound);
}
}
### Error Logging
Integrate with structured logging:
use tracing::error;
match do_operation().await { Ok(result) => result, Err(e) => { error!( error = %e, error_debug = ?e, // Full debug representation namespace = %namespace, "operation failed" ); return Err(e); } }
## References
- [thiserror documentation](https://docs.rs/thiserror)
- [anyhow documentation](https://docs.rs/anyhow)
- [Rust Error Handling](https://doc.rust-lang.org/book/ch09-00-error-handling.html)
- ADR-001: Rust for the Proxy
- ADR-013: Go Error Handling Strategy (parallel Go patterns)
## Revision History
- 2025-10-07: Initial draft and acceptance