1use axum::{
4 http::StatusCode,
5 response::{IntoResponse, Response},
6 Json,
7};
8use serde::Serialize;
9
10pub type ApiResult<T> = Result<T, ApiError>;
12
13#[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#[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 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
118impl 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}