Skip to main content

perfgate_client/
error.rs

1//! Error types for the perfgate client.
2//!
3//! This module defines all error conditions that can occur when interacting
4//! with the baseline service.
5
6use serde::Deserialize;
7use std::time::Duration;
8use thiserror::Error;
9
10/// Client error type.
11#[derive(Debug, Error)]
12pub enum ClientError {
13    /// HTTP request failed.
14    #[error("HTTP error: {status} - {message}")]
15    HttpError {
16        /// HTTP status code.
17        status: u16,
18        /// Error message from the server.
19        message: String,
20        /// Error code from the server.
21        code: Option<String>,
22    },
23
24    /// Authentication failed.
25    #[error("Authentication failed: {0}")]
26    AuthError(String),
27
28    /// Baseline not found.
29    #[error("Baseline not found: {0}")]
30    NotFoundError(String),
31
32    /// Invalid request data.
33    #[error("Validation error: {0}")]
34    ValidationError(String),
35
36    /// Response parsing failed.
37    #[error("Failed to parse response: {0}")]
38    ParseError(#[source] serde_json::Error),
39
40    /// Server unreachable or connection failed.
41    #[error("Connection error: {0}")]
42    ConnectionError(String),
43
44    /// Request timed out.
45    #[error("Request timed out after {0:?}")]
46    TimeoutError(Duration),
47
48    /// Server returned an error after all retries.
49    #[error("Request failed after {retries} retries: {message}")]
50    RetryExhausted {
51        /// Number of retry attempts.
52        retries: u32,
53        /// Final error message.
54        message: String,
55    },
56
57    /// Baseline already exists (conflict).
58    #[error("Baseline already exists: {0}")]
59    AlreadyExistsError(String),
60
61    /// Fallback storage error.
62    #[error("Fallback storage error: {0}")]
63    FallbackError(String),
64
65    /// No fallback storage available.
66    #[error("No fallback storage available")]
67    NoFallbackAvailable,
68
69    /// I/O error.
70    #[error("I/O error: {0}")]
71    IoError(#[source] std::io::Error),
72
73    /// URL parsing error.
74    #[error("Invalid URL: {0}")]
75    UrlError(#[from] url::ParseError),
76
77    /// Generic request error from reqwest.
78    #[error("Request error: {0}")]
79    RequestError(#[source] reqwest::Error),
80}
81
82impl ClientError {
83    /// Creates an HTTP error from a status code and response body.
84    pub fn from_http(status: u16, body: &str) -> Self {
85        // Try to parse the body as an API error response
86        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            // Map specific error codes to specific error types
91            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            // Fallback to generic HTTP error
105            ClientError::HttpError {
106                status,
107                message: body.to_string(),
108                code: None,
109            }
110        }
111    }
112
113    /// Returns true if this error indicates the server is unavailable.
114    pub fn is_connection_error(&self) -> bool {
115        matches!(
116            self,
117            ClientError::ConnectionError(_)
118                | ClientError::TimeoutError(_)
119                | ClientError::RetryExhausted { .. }
120        )
121    }
122
123    /// Returns true if this error could be retried.
124    pub fn is_retryable(&self) -> bool {
125        match self {
126            ClientError::HttpError { status, .. } => {
127                // Retry on 5xx errors and 429 (rate limited)
128                *status >= 500 || *status == 429
129            }
130            ClientError::ConnectionError(_) => true,
131            ClientError::TimeoutError(_) => true,
132            _ => false,
133        }
134    }
135}
136
137/// API error response from the server.
138#[derive(Debug, Clone, Deserialize)]
139pub struct ApiErrorResponse {
140    /// Error details.
141    pub error: ApiErrorBody,
142}
143
144/// API error body.
145#[derive(Debug, Clone, Deserialize)]
146pub struct ApiErrorBody {
147    /// Error code.
148    pub code: String,
149    /// Human-readable message.
150    pub message: String,
151    /// Additional details.
152    #[serde(default)]
153    pub details: Option<serde_json::Value>,
154    /// Request ID for tracing.
155    #[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        // 5xx errors are retryable
215        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        // 429 is retryable
220        assert!(ClientError::from_http(429, "rate limited").is_retryable());
221
222        // 4xx errors (except 429) are not retryable
223        assert!(!ClientError::from_http(400, "bad request").is_retryable());
224        assert!(!ClientError::from_http(404, "not found").is_retryable());
225
226        // Connection errors are retryable
227        assert!(ClientError::ConnectionError("failed".to_string()).is_retryable());
228    }
229}