Vapora/docs/adrs/0022-error-handling.md

286 lines
7.5 KiB
Markdown
Raw Normal View History

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