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
- Separation of Concerns: Domain logic no conoce HTTP (reusable en CLI, libraries)
- Reusability: Mismo error type usado por backend, frontend (via API), agents
- Type Safety: Compiler ensures all error cases handled
- 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)