286 lines
7.5 KiB
Markdown
286 lines
7.5 KiB
Markdown
# 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)
|