Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Error Handling

CRUDCrate provides comprehensive error handling with security-conscious responses.

ApiError Types

pub enum ApiError {
    /// Resource not found (404)
    NotFound,

    /// Invalid request data (400)
    BadRequest(String),

    /// Authentication required (401)
    Unauthorized,

    /// Permission denied (403)
    Forbidden,

    /// Resource conflict (409)
    Conflict(String),

    /// Validation failed (422)
    ValidationFailed(Vec<ValidationError>),

    /// Database error (500) - details logged, not exposed
    Database(String),

    /// Generic internal error (500)
    Internal(String),
}

HTTP Status Codes

ErrorStatusWhen
NotFound404Resource doesn’t exist
BadRequest400Invalid input format
Unauthorized401Missing/invalid auth
Forbidden403Insufficient permissions
Conflict409Duplicate key, constraint violation
ValidationFailed422Business rule validation failed
Database500Database error (sanitized)
Internal500Other server error

Response Format

// NotFound
{
  "error": "Not found"
}

// BadRequest
{
  "error": "Invalid filter field: unknown_field"
}

// ValidationFailed
{
  "error": "Validation failed",
  "details": [
    {"field": "email", "message": "Invalid email format"},
    {"field": "age", "message": "Must be at least 18"}
  ]
}

// Database (sanitized)
{
  "error": "Database error"
}

Using ApiError

In Handlers

use crudcrate::ApiError;

async fn get_item(
    Path(id): Path<i32>,
    Extension(db): Extension<DatabaseConnection>,
) -> Result<Json<Item>, ApiError> {
    let item = Entity::find_by_id(id)
        .one(&db)
        .await
        .map_err(|e| ApiError::Database(e.to_string()))?
        .ok_or(ApiError::NotFound)?;

    Ok(Json(item.into()))
}

In CRUDOperations

impl CRUDOperations for MyOperations {
    type Resource = Item;

    async fn before_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self,
        db: &C,
        data: &mut ItemCreate,
    ) -> Result<(), ApiError> {
        // Validation
        if data.name.is_empty() {
            return Err(ApiError::ValidationFailed(vec![
                ValidationError::new("name", "Name cannot be empty")
            ]));
        }

        // Permission check
        if !user_can_create(&data) {
            return Err(ApiError::Forbidden);
        }

        // Uniqueness check
        if name_exists(db, &data.name).await? {
            return Err(ApiError::Conflict("Name already exists".into()));
        }

        Ok(())
    }
}

Validation Errors

For detailed field-level errors:

use crudcrate::ValidationError;

fn validate_user(data: &UserCreate) -> Result<(), ApiError> {
    let mut errors = Vec::new();

    if data.email.is_empty() {
        errors.push(ValidationError::new("email", "Email is required"));
    } else if !data.email.contains('@') {
        errors.push(ValidationError::new("email", "Invalid email format"));
    }

    if data.password.len() < 8 {
        errors.push(ValidationError::new("password", "Password must be at least 8 characters"));
    }

    if data.age < 18 {
        errors.push(ValidationError::new("age", "Must be at least 18 years old"));
    }

    if errors.is_empty() {
        Ok(())
    } else {
        Err(ApiError::ValidationFailed(errors))
    }
}

Response:

{
  "error": "Validation failed",
  "details": [
    {"field": "email", "message": "Invalid email format"},
    {"field": "password", "message": "Password must be at least 8 characters"}
  ]
}

Error Conversion

ApiError implements From for common error types:

// Sea-ORM errors
impl From<DbErr> for ApiError {
    fn from(err: DbErr) -> Self {
        // Log full error internally
        eprintln!("Database error: {}", err);
        // Return sanitized response
        ApiError::Database("Database error".into())
    }
}

// Use with ? operator
async fn create_item(data: ItemCreate, db: &DatabaseConnection) -> Result<Item, ApiError> {
    let active_model: ActiveModel = data.into();
    let result = active_model.insert(db).await?;  // Auto-converts DbErr
    Ok(result.into())
}

Security Considerations

Never Expose Internal Details

// Bad: exposes internal error
Err(ApiError::Database(format!("SQL error: {}", e)))

// Good: log internally, sanitize response
eprintln!("Database error: {}", e);
Err(ApiError::Database("Database error".into()))

Handle Unique Constraint Violations

async fn create_user(data: UserCreate, db: &DatabaseConnection) -> Result<User, ApiError> {
    let result = Entity::insert(data.into())
        .exec(db)
        .await;

    match result {
        Ok(res) => { /* ... */ },
        Err(DbErr::RecordNotInserted) => {
            Err(ApiError::Conflict("User with this email already exists".into()))
        },
        Err(e) => {
            eprintln!("Insert error: {}", e);
            Err(ApiError::Database("Failed to create user".into()))
        }
    }
}

Rate Limit Errors

async fn check_rate_limit(user_id: i32) -> Result<(), ApiError> {
    // Implementation
    if is_rate_limited(user_id) {
        return Err(ApiError::BadRequest("Too many requests. Please wait.".into()));
    }
    Ok(())
}

Logging with Tracing

Integrate with the tracing crate:

use tracing::{error, warn, info};

impl CRUDOperations for MyOperations {
    async fn before_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
        &self,
        db: &C,
        id: i32,
    ) -> Result<(), ApiError> {
        let item = Entity::find_by_id(id).one(db).await?;

        match item {
            Some(item) => {
                info!(id = %id, name = %item.name, "Deleting item");
                Ok(())
            },
            None => {
                warn!(id = %id, "Attempted to delete non-existent item");
                Err(ApiError::NotFound)
            }
        }
    }
}

Custom Error Types

Extend ApiError for domain-specific errors:

#[derive(Debug)]
pub enum DomainError {
    InsufficientBalance(f64),
    ItemOutOfStock(String),
    OrderExpired,
}

impl From<DomainError> for ApiError {
    fn from(err: DomainError) -> Self {
        match err {
            DomainError::InsufficientBalance(amount) => {
                ApiError::BadRequest(format!("Insufficient balance. Need ${:.2} more", amount))
            },
            DomainError::ItemOutOfStock(item) => {
                ApiError::Conflict(format!("Item '{}' is out of stock", item))
            },
            DomainError::OrderExpired => {
                ApiError::BadRequest("Order has expired".into())
            },
        }
    }
}

Global Error Handler

Configure Axum for consistent error handling:

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, body) = match self {
            ApiError::NotFound => (
                StatusCode::NOT_FOUND,
                json!({"error": "Not found"})
            ),
            ApiError::BadRequest(msg) => (
                StatusCode::BAD_REQUEST,
                json!({"error": msg})
            ),
            ApiError::ValidationFailed(errors) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                json!({
                    "error": "Validation failed",
                    "details": errors
                })
            ),
            // ... other variants
        };

        (status, Json(body)).into_response()
    }
}

Testing Errors

#[tokio::test]
async fn test_not_found() {
    let app = create_test_app().await;

    let response = app
        .oneshot(Request::get("/items/999999").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::NOT_FOUND);

    let body: Value = parse_body(response).await;
    assert_eq!(body["error"], "Not found");
}

#[tokio::test]
async fn test_validation_error() {
    let app = create_test_app().await;

    let response = app
        .oneshot(
            Request::post("/users")
                .header("Content-Type", "application/json")
                .body(Body::from(r#"{"email": "invalid"}"#))
                .unwrap()
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

Next Steps