vex_api/
error.rs

1//! API error types with proper HTTP mapping
2
3use axum::{
4    http::StatusCode,
5    response::{IntoResponse, Response},
6    Json,
7};
8use serde::Serialize;
9
10/// API result type alias
11pub type ApiResult<T> = Result<T, ApiError>;
12
13/// Comprehensive API error type
14#[derive(Debug, thiserror::Error)]
15pub enum ApiError {
16    #[error("Not found: {0}")]
17    NotFound(String),
18
19    #[error("Bad request: {0}")]
20    BadRequest(String),
21
22    #[error("Unauthorized: {0}")]
23    Unauthorized(String),
24
25    #[error("Forbidden: {0}")]
26    Forbidden(String),
27
28    #[error("Conflict: {0}")]
29    Conflict(String),
30
31    #[error("Rate limited")]
32    RateLimited,
33
34    #[error("Service unavailable: {0}")]
35    ServiceUnavailable(String),
36
37    #[error("Internal error: {0}")]
38    Internal(String),
39
40    #[error("Circuit open: {0}")]
41    CircuitOpen(String),
42
43    #[error("Timeout")]
44    Timeout,
45
46    #[error("Validation error: {0}")]
47    Validation(String),
48}
49
50/// Error response body
51#[derive(Debug, Serialize)]
52pub struct ErrorResponse {
53    pub error: ErrorBody,
54}
55
56#[derive(Debug, Serialize)]
57pub struct ErrorBody {
58    pub code: String,
59    pub message: String,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub details: Option<serde_json::Value>,
62}
63
64impl IntoResponse for ApiError {
65    fn into_response(self) -> Response {
66        let (status, code, message) = match &self {
67            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.clone()),
68            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "BAD_REQUEST", msg.clone()),
69            ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.clone()),
70            ApiError::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.clone()),
71            ApiError::Conflict(msg) => (StatusCode::CONFLICT, "CONFLICT", msg.clone()),
72            ApiError::RateLimited => (
73                StatusCode::TOO_MANY_REQUESTS,
74                "RATE_LIMITED",
75                "Too many requests".to_string(),
76            ),
77            ApiError::ServiceUnavailable(msg) => (
78                StatusCode::SERVICE_UNAVAILABLE,
79                "SERVICE_UNAVAILABLE",
80                msg.clone(),
81            ),
82            ApiError::Internal(msg) => {
83                // Don't expose internal errors to clients
84                tracing::error!(error = %msg, "Internal error");
85                (
86                    StatusCode::INTERNAL_SERVER_ERROR,
87                    "INTERNAL_ERROR",
88                    "An internal error occurred".to_string(),
89                )
90            }
91            ApiError::CircuitOpen(msg) => {
92                (StatusCode::SERVICE_UNAVAILABLE, "CIRCUIT_OPEN", msg.clone())
93            }
94            ApiError::Timeout => (
95                StatusCode::GATEWAY_TIMEOUT,
96                "TIMEOUT",
97                "Request timed out".to_string(),
98            ),
99            ApiError::Validation(msg) => (
100                StatusCode::UNPROCESSABLE_ENTITY,
101                "VALIDATION_ERROR",
102                msg.clone(),
103            ),
104        };
105
106        let body = ErrorResponse {
107            error: ErrorBody {
108                code: code.to_string(),
109                message,
110                details: None,
111            },
112        };
113
114        (status, Json(body)).into_response()
115    }
116}
117
118// Convenient conversions
119impl From<std::io::Error> for ApiError {
120    fn from(e: std::io::Error) -> Self {
121        ApiError::Internal(e.to_string())
122    }
123}
124
125impl From<serde_json::Error> for ApiError {
126    fn from(e: serde_json::Error) -> Self {
127        ApiError::BadRequest(format!("JSON error: {}", e))
128    }
129}
130
131impl From<vex_llm::LlmError> for ApiError {
132    fn from(e: vex_llm::LlmError) -> Self {
133        match e {
134            vex_llm::LlmError::ConnectionFailed(_) => {
135                ApiError::ServiceUnavailable("LLM service unavailable".to_string())
136            }
137            vex_llm::LlmError::RequestFailed(msg) => ApiError::Internal(msg),
138            vex_llm::LlmError::InvalidResponse(msg) => ApiError::Internal(msg),
139            vex_llm::LlmError::RateLimited => ApiError::RateLimited,
140            vex_llm::LlmError::NotAvailable => {
141                ApiError::ServiceUnavailable("LLM provider not available".to_string())
142            }
143            vex_llm::LlmError::InputTooLarge(size, max) => {
144                ApiError::BadRequest(format!("Input too large: {} bytes (max {})", size, max))
145            }
146        }
147    }
148}
149
150impl From<vex_persist::StorageError> for ApiError {
151    fn from(e: vex_persist::StorageError) -> Self {
152        match e {
153            vex_persist::StorageError::NotFound(msg) => ApiError::NotFound(msg),
154            vex_persist::StorageError::AlreadyExists(msg) => ApiError::Conflict(msg),
155            _ => ApiError::Internal(e.to_string()),
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    #[allow(unused_imports)]
164    use axum::body::Body;
165    use http_body_util::BodyExt;
166
167    #[tokio::test]
168    async fn test_error_response() {
169        let error = ApiError::NotFound("User not found".to_string());
170        let response = error.into_response();
171
172        assert_eq!(response.status(), StatusCode::NOT_FOUND);
173
174        let body = response.into_body();
175        let bytes = body.collect().await.unwrap().to_bytes();
176        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
177
178        assert_eq!(json["error"]["code"], "NOT_FOUND");
179    }
180}