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    /// JSON serialization or deserialization error.
82    #[error("JSON error: {0}")]
83    SerializationError(#[from] serde_json::Error),
84}
85
86impl ClientError {
87    /// Creates an HTTP error from a status code and response body.
88    pub fn from_http(status: u16, body: &str) -> Self {
89        // Try to parse the body as an API error response
90        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            // Map specific error codes to specific error types
95            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            // Fallback to generic HTTP error
109            ClientError::HttpError {
110                status,
111                message: body.to_string(),
112                code: None,
113            }
114        }
115    }
116
117    /// Returns true if this error indicates the server is unavailable.
118    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    /// Returns true if this error could be retried.
129    pub fn is_retryable(&self) -> bool {
130        match self {
131            ClientError::HttpError { status, .. } => {
132                // Retry on 5xx errors and 429 (rate limited)
133                *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/// API error response from the server.
144#[derive(Debug, Clone, Deserialize)]
145pub struct ApiErrorResponse {
146    /// Error details.
147    pub error: ApiErrorBody,
148}
149
150/// API error body.
151#[derive(Debug, Clone, Deserialize)]
152pub struct ApiErrorBody {
153    /// Error code.
154    pub code: String,
155    /// Human-readable message.
156    pub message: String,
157    /// Additional details.
158    #[serde(default)]
159    pub details: Option<serde_json::Value>,
160    /// Request ID for tracing.
161    #[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        // 5xx errors are retryable
221        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        // 429 is retryable
226        assert!(ClientError::from_http(429, "rate limited").is_retryable());
227
228        // 4xx errors (except 429) are not retryable
229        assert!(!ClientError::from_http(400, "bad request").is_retryable());
230        assert!(!ClientError::from_http(404, "not found").is_retryable());
231
232        // Connection errors are retryable
233        assert!(ClientError::ConnectionError("failed".to_string()).is_retryable());
234    }
235}