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
82impl ClientError {
83 pub fn from_http(status: u16, body: &str) -> Self {
85 if let Ok(api_error) = serde_json::from_str::<ApiErrorResponse>(body) {
87 let code = api_error.error.code.clone();
88 let message = api_error.error.message;
89
90 match api_error.error.code.as_str() {
92 "UNAUTHORIZED" => ClientError::AuthError(message),
93 "FORBIDDEN" => ClientError::AuthError(message),
94 "NOT_FOUND" => ClientError::NotFoundError(message),
95 "VALIDATION_ERROR" => ClientError::ValidationError(message),
96 "ALREADY_EXISTS" => ClientError::AlreadyExistsError(message),
97 _ => ClientError::HttpError {
98 status,
99 message,
100 code: Some(code),
101 },
102 }
103 } else {
104 ClientError::HttpError {
106 status,
107 message: body.to_string(),
108 code: None,
109 }
110 }
111 }
112
113 pub fn is_connection_error(&self) -> bool {
115 matches!(
116 self,
117 ClientError::ConnectionError(_)
118 | ClientError::TimeoutError(_)
119 | ClientError::RetryExhausted { .. }
120 )
121 }
122
123 pub fn is_retryable(&self) -> bool {
125 match self {
126 ClientError::HttpError { status, .. } => {
127 *status >= 500 || *status == 429
129 }
130 ClientError::ConnectionError(_) => true,
131 ClientError::TimeoutError(_) => true,
132 _ => false,
133 }
134 }
135}
136
137#[derive(Debug, Clone, Deserialize)]
139pub struct ApiErrorResponse {
140 pub error: ApiErrorBody,
142}
143
144#[derive(Debug, Clone, Deserialize)]
146pub struct ApiErrorBody {
147 pub code: String,
149 pub message: String,
151 #[serde(default)]
153 pub details: Option<serde_json::Value>,
154 #[serde(default)]
156 pub request_id: Option<String>,
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn test_from_http_unauthorized() {
165 let body = r#"{"error":{"code":"UNAUTHORIZED","message":"Invalid API key"}}"#;
166 let error = ClientError::from_http(401, body);
167 assert!(matches!(error, ClientError::AuthError(_)));
168 }
169
170 #[test]
171 fn test_from_http_not_found() {
172 let body = r#"{"error":{"code":"NOT_FOUND","message":"Baseline not found"}}"#;
173 let error = ClientError::from_http(404, body);
174 assert!(matches!(error, ClientError::NotFoundError(_)));
175 }
176
177 #[test]
178 fn test_from_http_validation_error() {
179 let body = r#"{"error":{"code":"VALIDATION_ERROR","message":"Invalid benchmark name"}}"#;
180 let error = ClientError::from_http(400, body);
181 assert!(matches!(error, ClientError::ValidationError(_)));
182 }
183
184 #[test]
185 fn test_from_http_generic() {
186 let body = r#"{"error":{"code":"INTERNAL_ERROR","message":"Something went wrong"}}"#;
187 let error = ClientError::from_http(500, body);
188 assert!(matches!(error, ClientError::HttpError { .. }));
189 }
190
191 #[test]
192 fn test_from_http_malformed() {
193 let body = "Not JSON";
194 let error = ClientError::from_http(500, body);
195 assert!(matches!(
196 error,
197 ClientError::HttpError {
198 status: 500,
199 message: _,
200 code: None,
201 }
202 ));
203 }
204
205 #[test]
206 fn test_is_connection_error() {
207 assert!(ClientError::ConnectionError("failed".to_string()).is_connection_error());
208 assert!(ClientError::TimeoutError(Duration::from_secs(30)).is_connection_error());
209 assert!(!ClientError::NotFoundError("not found".to_string()).is_connection_error());
210 }
211
212 #[test]
213 fn test_is_retryable() {
214 assert!(ClientError::from_http(500, "error").is_retryable());
216 assert!(ClientError::from_http(502, "error").is_retryable());
217 assert!(ClientError::from_http(503, "error").is_retryable());
218
219 assert!(ClientError::from_http(429, "rate limited").is_retryable());
221
222 assert!(!ClientError::from_http(400, "bad request").is_retryable());
224 assert!(!ClientError::from_http(404, "not found").is_retryable());
225
226 assert!(ClientError::ConnectionError("failed".to_string()).is_retryable());
228 }
229}