7.5 KiB
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
- 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:
// 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)