Unified error handling library for Pleme platform services.
This library implements Railway-Oriented Programming (Scott Wlaschin) principles:
- Errors are first-class citizens
- Two tracks: success path vs error path
- Composable error handling
- Fail-fast with meaningful context
- Railway-Oriented Programming (Scott Wlaschin) - Functional error handling
- Domain-Driven Design (Eric Evans) - Domain-specific error types
- 12-Factor App - Treat errors as event streams
- Rust Error Handling - thiserror for custom errors, anyhow for context
-
context- Error context and chaining with anyhow -
graphql- GraphQL error conversion for async-graphql -
http-errors- HTTP status code conversion for Axum -
logging- Structured error logging with tracing -
database- Database error conversions (sqlx, Redis) -
web- Full web stack (graphql + http-errors + logging) -
full- All features enabled
use pleme_error::{ServiceError, Result};
fn get_user(id: &str) -> Result<User> {
let user = db.find(id)
.ok_or_else(|| ServiceError::not_found("User", id))?;
Ok(user)
}use pleme_error::{ServiceError, Result};
// Success track: User -> Validation -> Persistence -> Email
// Error track: Any error short-circuits to error response
fn register_user(input: RegisterInput) -> Result<User> {
let user = validate_input(input)?; // ← May jump to error track
let saved = persist_user(user)?; // ← May jump to error track
send_welcome_email(&saved)?; // ← May jump to error track
Ok(saved) // ← Success track
}// Not found
ServiceError::not_found("User", user_id)
// Invalid input
ServiceError::invalid_input("Email must be valid")
ServiceError::invalid_field("email", "Must be valid")
// Database error
db.query().await
.map_err(|e| ServiceError::database("Query failed", e))?
// External service error
stripe.charge(amount).await
.map_err(|e| ServiceError::external_service("Stripe", "Payment failed", e))?
// Authentication/Authorization
ServiceError::Unauthenticated("Login required".to_string())
ServiceError::PermissionDenied("Admin only".to_string())
// Business rules
ServiceError::BusinessRule("Cannot refund shipped orders".to_string())
// Conflict
ServiceError::Conflict("Email already registered".to_string())[dependencies]
pleme-error = { path = "../pleme-error", features = ["graphql"] }use pleme_error::{ServiceError, Result};
use async_graphql::{Context, Object};
#[Object]
impl Query {
async fn user(&self, ctx: &Context<'_>, id: String) -> Result<User> {
let user = db.find(&id).await
.ok_or_else(|| ServiceError::not_found("User", id))?;
Ok(user) // Automatically converts to GraphQL error on Err
}
}GraphQL error response:
{
"errors": [{
"message": "Not found: User with 550e8400-e29b-41d4-a716-446655440000",
"extensions": {
"code": "NOT_FOUND"
}
}]
}[dependencies]
pleme-error = { path = "../pleme-error", features = ["http-errors", "serialization"] }use pleme_error::{ServiceError, Result};
use axum::{Router, routing::get, Json};
async fn get_user(Path(id): Path<String>) -> Result<Json<User>> {
let user = db.find(&id).await
.ok_or_else(|| ServiceError::not_found("User", id))?;
Ok(Json(user)) // Automatically converts to HTTP response on Err
}HTTP error response (404 Not Found):
{
"error": "NOT_FOUND",
"message": "Not found: User with 123",
"field": null,
"details": null
}[dependencies]
pleme-error = { path = "../pleme-error", features = ["database"] }use pleme_error::Result;
async fn create_user(email: String) -> Result<User> {
let user = sqlx::query_as!(
User,
"INSERT INTO users (email) VALUES ($1) RETURNING *",
email
)
.fetch_one(&pool)
.await?; // Automatically converts sqlx::Error to ServiceError
Ok(user)
}Constraint violations become ServiceError::Conflict:
// Duplicate email → ServiceError::Conflict("Constraint violation: users_email_key")[dependencies]
pleme-error = { path = "../pleme-error", features = ["logging"] }use pleme_error::{ServiceError, log_error};
fn process_payment(order_id: &str) -> Result<Payment> {
match charge_payment(order_id) {
Ok(payment) => Ok(payment),
Err(e) => {
log_error(&e, "payment_processing"); // Structured logging
Err(e)
}
}
}Output:
WARN Service error occurred, error="External service error: Stripe - Card declined", context="payment_processing"
[dependencies]
pleme-error = { path = "../pleme-error", features = ["context"] }use pleme_error::{ServiceError, Result, Context};
fn load_config() -> Result<Config> {
let file = std::fs::read_to_string("config.yaml")
.context("Failed to read config.yaml")?; // Add context
let config: Config = serde_yaml::from_str(&file)
.context("Failed to parse config.yaml")?; // Add context
Ok(config)
}use pleme_error::ServiceError;
async fn with_retry<F, T>(mut f: F, max_retries: u32) -> Result<T>
where
F: FnMut() -> Result<T>,
{
let mut attempts = 0;
loop {
match f() {
Ok(value) => return Ok(value),
Err(e) if e.is_retryable() && attempts < max_retries => {
attempts += 1;
tokio::time::sleep(Duration::from_millis(100 * 2_u64.pow(attempts))).await;
}
Err(e) => return Err(e),
}
}
}Errors default to denial. PermissionDenied and Unauthenticated are explicit.
Error messages guide users and operators to solutions.
// ❌ BAD
ServiceError::internal_msg("Error")
// ✅ GOOD
ServiceError::database("Failed to connect to PostgreSQL at db.example.com:5432", error)GraphQL and HTTP responses include machine-readable error codes (NOT_FOUND, INVALID_INPUT, etc.)
Errors know if they're severe (database failure) vs expected (not found).
if error.is_severe() {
tracing::error!("Critical error: {}", error);
} else {
tracing::warn!("Expected error: {}", error);
}fn validate_email(email: &str) -> Result<()> {
if !email.contains('@') {
return Err(ServiceError::invalid_field("email", "Must contain @"));
}
Ok(())
}// NOT FOUND: Expected resource doesn't exist (404)
db.find_by_id(id).await
.ok_or_else(|| ServiceError::not_found("User", id))?
// EMPTY: Query returned no results (200 OK with [])
let users: Vec<User> = db.find_all().await?; // Returns Ok(vec![])if order.status == OrderStatus::Shipped {
return Err(ServiceError::BusinessRule(
"Cannot cancel shipped orders".to_string()
));
}let payment = stripe_client
.charge(amount)
.await
.map_err(|e| ServiceError::external_service("Stripe", "Charge failed", e))?;// BEFORE
#[derive(Error, Debug)]
pub enum MyServiceError {
#[error("Not found")]
NotFound,
#[error("Database error: {0}")]
Database(String),
}
// AFTER
use pleme_error::{ServiceError, Result};
// Just use ServiceError directly!// BEFORE
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_one(&pool)
.await?; // Exposes sqlx::Error to GraphQL/HTTP
// AFTER
use pleme_error::{ServiceError, Result};
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_one(&pool)
.await?; // Automatically converts to ServiceError with proper HTTP status- Use descriptive error messages - Help operators debug issues
- Include context - What operation failed? What resource?
- Don't leak sensitive data - No database connection strings, API keys, etc.
-
Use proper error types -
NotFoundvsInvalidInputvsInternal - Log severe errors - Database failures, panics, external service outages
- Don't log expected errors - Not found, validation failures
-
Add retry logic for retryable errors - Use
is_retryable()
#[cfg(test)]
mod tests {
use pleme_error::{ServiceError, Result};
#[test]
fn test_error_handling() {
let result: Result<User> = Err(ServiceError::not_found("User", "123"));
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ServiceError::NotFound { .. }
));
}
#[test]
fn test_retryable() {
let db_error = ServiceError::database_msg("Connection timeout");
assert!(db_error.is_retryable());
let not_found = ServiceError::not_found("User", "123");
assert!(!not_found.is_retryable());
}
}- Railway-Oriented Programming - Scott Wlaschin
- Rust Error Handling
- thiserror - Custom error types
- anyhow - Error context and chaining
