1use serde::Deserialize;
7use std::time::Duration;
8use thiserror::Error;
9
10#[derive(Debug, Error)]
12pub enum ClientError {
13 #[error("HTTP error: {status} - {message}")]
15 HttpError {
16 status: u16,
18 message: String,
20 code: Option<String>,
22 },
23
24 #[error("Authentication failed: {0}")]
26 AuthError(String),
27
28 #[error("Baseline not found: {0}")]
30 NotFoundError(String),
31
32 #[error("Validation error: {0}")]
34 ValidationError(String),
35
36 #[error("Failed to parse response: {0}")]
38 ParseError(#[source] serde_json::Error),
39
40 #[error("Connection error: {0}")]
42 ConnectionError(String),
43
44 #[error("Request timed out after {0:?}")]
46 TimeoutError(Duration),
47
48 #[error("Request failed after {retries} retries: {message}")]
50 RetryExhausted {
51 retries: u32,
53 message: String,
55 },
56
57 #[error("Baseline already exists: {0}")]
59 AlreadyExistsError(String),
60
61 #[error("Fallback storage error: {0}")]
63 FallbackError(String),
64
65 #[error("No fallback storage available")]
67 NoFallbackAvailable,
68
69 #[error("I/O error: {0}")]
71 IoError(#[source] std::io::Error),
72
73 #[error("Invalid URL: {0}")]
75 UrlError(#[from] url::ParseError),
76
77 #[error("Request error: {0}")]
79 RequestError(#[source] reqwest::Error),
80
81 #[error("JSON error: {0}")]
83 SerializationError(#[from] serde_json::Error),
84}
85
86impl ClientError {
87 pub fn from_http(status: u16, body: &str) -> Self {
89 if let Ok(api_error) = serde_json::from_str::<ApiErrorResponse>(body) {
91 let code = api_error.error.code.clone();
92 let message = api_error.error.message;
93
94 match api_error.error.code.as_str() {
96 "UNAUTHORIZED" => ClientError::AuthError(message),
97 "FORBIDDEN" => ClientError::AuthError(message),
98 "NOT_FOUND" => ClientError::NotFoundError(message),
99 "VALIDATION_ERROR" => ClientError::ValidationError(message),
100 "ALREADY_EXISTS" => ClientError::AlreadyExistsError(message),
101 _ => ClientError::HttpError {
102 status,
103 message,
104 code: Some(code),
105 },
106 }
107 } else {
108 ClientError::HttpError {
110 status,
111 message: body.to_string(),
112 code: None,
113 }
114 }
115 }
116
117 pub fn is_connection_error(&self) -> bool {
119 match self {
120 ClientError::ConnectionError(_)
121 | ClientError::TimeoutError(_)
122 | ClientError::RetryExhausted { .. } => true,
123 ClientError::RequestError(e) => e.is_connect() || e.is_timeout(),
124 _ => false,
125 }
126 }
127
128 pub fn is_retryable(&self) -> bool {
130 match self {
131 ClientError::HttpError { status, .. } => {
132 *status >= 500 || *status == 429
134 }
135 ClientError::ConnectionError(_) => true,
136 ClientError::TimeoutError(_) => true,
137 ClientError::RequestError(e) => e.is_connect() || e.is_timeout(),
138 _ => false,
139 }
140 }
141}
142
143#[derive(Debug, Clone, Deserialize)]
145pub struct ApiErrorResponse {
146 pub error: ApiErrorBody,
148}
149
150#[derive(Debug, Clone, Deserialize)]
152pub struct ApiErrorBody {
153 pub code: String,
155 pub message: String,
157 #[serde(default)]
159 pub details: Option<serde_json::Value>,
160 #[serde(default)]
162 pub request_id: Option<String>,
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn test_from_http_unauthorized() {
171 let body = r#"{"error":{"code":"UNAUTHORIZED","message":"Invalid API key"}}"#;
172 let error = ClientError::from_http(401, body);
173 assert!(matches!(error, ClientError::AuthError(_)));
174 }
175
176 #[test]
177 fn test_from_http_not_found() {
178 let body = r#"{"error":{"code":"NOT_FOUND","message":"Baseline not found"}}"#;
179 let error = ClientError::from_http(404, body);
180 assert!(matches!(error, ClientError::NotFoundError(_)));
181 }
182
183 #[test]
184 fn test_from_http_validation_error() {
185 let body = r#"{"error":{"code":"VALIDATION_ERROR","message":"Invalid benchmark name"}}"#;
186 let error = ClientError::from_http(400, body);
187 assert!(matches!(error, ClientError::ValidationError(_)));
188 }
189
190 #[test]
191 fn test_from_http_generic() {
192 let body = r#"{"error":{"code":"INTERNAL_ERROR","message":"Something went wrong"}}"#;
193 let error = ClientError::from_http(500, body);
194 assert!(matches!(error, ClientError::HttpError { .. }));
195 }
196
197 #[test]
198 fn test_from_http_malformed() {
199 let body = "Not JSON";
200 let error = ClientError::from_http(500, body);
201 assert!(matches!(
202 error,
203 ClientError::HttpError {
204 status: 500,
205 message: _,
206 code: None,
207 }
208 ));
209 }
210
211 #[test]
212 fn test_is_connection_error() {
213 assert!(ClientError::ConnectionError("failed".to_string()).is_connection_error());
214 assert!(ClientError::TimeoutError(Duration::from_secs(30)).is_connection_error());
215 assert!(!ClientError::NotFoundError("not found".to_string()).is_connection_error());
216 }
217
218 #[test]
219 fn test_is_retryable() {
220 assert!(ClientError::from_http(500, "error").is_retryable());
222 assert!(ClientError::from_http(502, "error").is_retryable());
223 assert!(ClientError::from_http(503, "error").is_retryable());
224
225 assert!(ClientError::from_http(429, "rate limited").is_retryable());
227
228 assert!(!ClientError::from_http(400, "bad request").is_retryable());
230 assert!(!ClientError::from_http(404, "not found").is_retryable());
231
232 assert!(ClientError::ConnectionError("failed".to_string()).is_retryable());
234 }
235}