pleme-error 0.1.0

Unified error handling library for Pleme platform
Documentation

pleme-error

Unified error handling library for Pleme platform services.

Philosophy

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

Industry Standards

  • 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

Features

  • 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

Usage

Basic Usage

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)
}

Railway-Oriented Programming

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
}

Error Types

// 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())

GraphQL Integration

[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"
    }
  }]
}

HTTP/Axum Integration

[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
}

Database Integration

[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")

Structured Logging

[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"

Error Context (anyhow pattern)

[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)
}

Retry Logic

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),
        }
    }
}

Design Principles

1. Fail-Closed Security

Errors default to denial. PermissionDenied and Unauthenticated are explicit.

2. Meaningful Messages

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)

3. Structured Error Codes

GraphQL and HTTP responses include machine-readable error codes (NOT_FOUND, INVALID_INPUT, etc.)

4. Severity Levels

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);
}

Error Handling Patterns

Pattern 1: Validation Errors

fn validate_email(email: &str) -> Result<()> {
    if !email.contains('@') {
        return Err(ServiceError::invalid_field("email", "Must contain @"));
    }
    Ok(())
}

Pattern 2: Not Found vs Empty

// 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![])

Pattern 3: Business Rule Violations

if order.status == OrderStatus::Shipped {
    return Err(ServiceError::BusinessRule(
        "Cannot cancel shipped orders".to_string()
    ));
}

Pattern 4: External Service Failures

let payment = stripe_client
    .charge(amount)
    .await
    .map_err(|e| ServiceError::external_service("Stripe", "Charge failed", e))?;

Migration Guide

From Custom Error Types

// 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!

From Direct Database Errors

// 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

Best Practices

  1. Use descriptive error messages - Help operators debug issues
  2. Include context - What operation failed? What resource?
  3. Don't leak sensitive data - No database connection strings, API keys, etc.
  4. Use proper error types - NotFound vs InvalidInput vs Internal
  5. Log severe errors - Database failures, panics, external service outages
  6. Don't log expected errors - Not found, validation failures
  7. Add retry logic for retryable errors - Use is_retryable()

Testing

#[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());
    }
}

See Also