# 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**:
```rust
// 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**:
```rust
// 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**:
```rust
// 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**:
```rust
// 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

```bash
# 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](https://docs.rs/thiserror/latest/thiserror/)
- `/crates/vapora-shared/src/error.rs` (domain errors)
- `/crates/vapora-backend/src/api/error.rs` (HTTP wrapper)

---

**Related ADRs**: ADR-024 (Service Architecture)
