Vapora/docs/adrs/0022-error-handling.md
Jesús Pérez 7110ffeea2
Some checks failed
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
chore: extend doc: adr, tutorials, operations, etc
2026-01-12 03:32:47 +00:00

7.5 KiB

ADR-022: Two-Tier Error Handling (thiserror + HTTP Wrapper)

Status: Accepted | Implemented Date: 2024-11-01 Deciders: Backend Architecture Team Technical Story: Separating domain errors from HTTP response concerns


Decision

Implementar two-tier error handling: thiserror para domain errors, ApiError wrapper para HTTP responses.


Rationale

  1. Separation of Concerns: Domain logic no conoce HTTP (reusable en CLI, libraries)
  2. Reusability: Mismo error type usado por backend, frontend (via API), agents
  3. Type Safety: Compiler ensures all error cases handled
  4. HTTP Mapping: Clean mapping from domain errors to HTTP status codes

Alternatives Considered

Single Error Type (Mixed Domain + HTTP)

  • Pros: Simple
  • Cons: Domain logic coupled to HTTP, not reusable

Error Strings Only

  • Pros: Simple, flexible
  • Cons: No type safety, easy to forget cases

Two-Tier (Domain + HTTP wrapper) (CHOSEN)

  • Clean separation, reusable, type-safe

Trade-offs

Pros:

  • Domain logic independent of HTTP
  • Error types reusable in different contexts
  • Type-safe error handling
  • Explicit HTTP status code mapping

Cons:

  • ⚠️ Two error types to maintain
  • ⚠️ Conversion logic between layers
  • ⚠️ Slightly more verbose

Implementation

Domain Error Type:

// crates/vapora-shared/src/error.rs

use thiserror::Error;

#[derive(Error, Debug)]
pub enum VaporaError {
    #[error("Project not found: {0}")]
    ProjectNotFound(String),

    #[error("Task not found: {0}")]
    TaskNotFound(String),

    #[error("Unauthorized access to resource: {0}")]
    Unauthorized(String),

    #[error("Agent {agent_id} failed with: {reason}")]
    AgentExecutionFailed { agent_id: String, reason: String },

    #[error("Budget exceeded for role {role}: spent ${spent}, limit ${limit}")]
    BudgetExceeded { role: String, spent: u32, limit: u32 },

    #[error("Database error: {0}")]
    DatabaseError(#[from] surrealdb::Error),

    #[error("External service error: {service}: {message}")]
    ExternalServiceError { service: String, message: String },

    #[error("Invalid request: {0}")]
    ValidationError(String),

    #[error("Internal server error: {0}")]
    Internal(String),
}

pub type Result<T> = std::result::Result<T, VaporaError>;

HTTP Wrapper Type:

// crates/vapora-backend/src/api/error.rs

use serde::{Deserialize, Serialize};
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use vapora_shared::error::VaporaError;

#[derive(Serialize, Deserialize, Debug)]
pub struct ApiError {
    pub code: String,
    pub message: String,
    pub status: u16,
}

impl ApiError {
    pub fn new(code: impl Into<String>, message: impl Into<String>, status: u16) -> Self {
        Self {
            code: code.into(),
            message: message.into(),
            status,
        }
    }
}

// Convert domain error to HTTP response
impl From<VaporaError> for ApiError {
    fn from(err: VaporaError) -> Self {
        match err {
            VaporaError::ProjectNotFound(id) => {
                ApiError::new("NOT_FOUND", format!("Project {} not found", id), 404)
            }
            VaporaError::TaskNotFound(id) => {
                ApiError::new("NOT_FOUND", format!("Task {} not found", id), 404)
            }
            VaporaError::Unauthorized(reason) => {
                ApiError::new("UNAUTHORIZED", reason, 401)
            }
            VaporaError::ValidationError(msg) => {
                ApiError::new("BAD_REQUEST", msg, 400)
            }
            VaporaError::BudgetExceeded { role, spent, limit } => {
                ApiError::new(
                    "BUDGET_EXCEEDED",
                    format!("Role {} budget exceeded: ${}/{}", role, spent, limit),
                    429,  // Too Many Requests
                )
            }
            VaporaError::AgentExecutionFailed { agent_id, reason } => {
                ApiError::new(
                    "AGENT_ERROR",
                    format!("Agent {} execution failed: {}", agent_id, reason),
                    503,  // Service Unavailable
                )
            }
            VaporaError::ExternalServiceError { service, message } => {
                ApiError::new(
                    "SERVICE_ERROR",
                    format!("External service {} error: {}", service, message),
                    502,  // Bad Gateway
                )
            }
            VaporaError::DatabaseError(db_err) => {
                ApiError::new("DATABASE_ERROR", "Database operation failed", 500)
            }
            VaporaError::Internal(msg) => {
                ApiError::new("INTERNAL_ERROR", msg, 500)
            }
        }
    }
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
        (status, Json(self)).into_response()
    }
}

Usage in Handlers:

// crates/vapora-backend/src/api/projects.rs

pub async fn get_project(
    State(app_state): State<AppState>,
    Path(project_id): Path<String>,
) -> Result<Json<Project>, ApiError> {
    let user = get_current_user()?;

    // Service returns VaporaError
    let project = app_state
        .project_service
        .get_project(&user.tenant_id, &project_id)
        .await
        .map_err(ApiError::from)?;  // Convert to HTTP error

    Ok(Json(project))
}

Usage in Services:

// crates/vapora-backend/src/services/project_service.rs

pub async fn get_project(
    &self,
    tenant_id: &str,
    project_id: &str,
) -> Result<Project> {
    let project = self
        .db
        .query("SELECT * FROM projects WHERE id = $1 AND tenant_id = $2")
        .bind((project_id, tenant_id))
        .await?  // ? propagates database errors
        .take::<Option<Project>>(0)?
        .ok_or_else(|| VaporaError::ProjectNotFound(project_id.to_string()))?;

    Ok(project)
}

Key Files:

  • /crates/vapora-shared/src/error.rs (domain errors)
  • /crates/vapora-backend/src/api/error.rs (HTTP wrapper)
  • /crates/vapora-backend/src/api/ (handlers using errors)
  • /crates/vapora-backend/src/services/ (services using errors)

Verification

# Test error creation and conversion
cargo test -p vapora-backend test_error_conversion

# Test HTTP status code mapping
cargo test -p vapora-backend test_error_status_codes

# Test error propagation with ?
cargo test -p vapora-backend test_error_propagation

# Test API responses with errors
cargo test -p vapora-backend test_api_error_response

# Integration: full error flow
cargo test -p vapora-backend test_error_full_flow

Expected Output:

  • Domain errors created correctly
  • Status codes mapped appropriately
  • Error messages clear and helpful
  • HTTP responses valid JSON
  • Error propagation with ? works

Consequences

Error Handling Pattern

  • Use ? operator for propagation
  • Convert at HTTP boundary only
  • Domain logic error-agnostic

Maintainability

  • Errors centralized in shared crate
  • HTTP mapping documented in one place
  • Easy to add new error types

Reusability

  • Same error type in CLI tools
  • Agents can use domain errors
  • Frontend consumes HTTP errors

References

  • thiserror Documentation
  • /crates/vapora-shared/src/error.rs (domain errors)
  • /crates/vapora-backend/src/api/error.rs (HTTP wrapper)

Related ADRs: ADR-024 (Service Architecture)