# 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 = std::result::Result; ``` **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, message: impl Into, status: u16) -> Self { Self { code: code.into(), message: message.into(), status, } } } // Convert domain error to HTTP response impl From 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, Path(project_id): Path, ) -> Result, 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 { 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::>(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)