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

# 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:

    #![allow(unused)]
    fn main() {
    // 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:

    #![allow(unused)]
    fn main() {
    // 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:

    #![allow(unused)]
    fn main() {
    // 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:

    #![allow(unused)]
    fn main() {
    // 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)