use serde::Deserialize;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ClientError {
#[error("HTTP error: {status} - {message}")]
HttpError {
status: u16,
message: String,
code: Option<String>,
},
#[error("Authentication failed: {0}")]
AuthError(String),
#[error("Baseline not found: {0}")]
NotFoundError(String),
#[error("Validation error: {0}")]
ValidationError(String),
#[error("Failed to parse response: {0}")]
ParseError(#[source] serde_json::Error),
#[error("Connection error: {0}")]
ConnectionError(String),
#[error("Request timed out after {0:?}")]
TimeoutError(Duration),
#[error("Request failed after {retries} retries: {message}")]
RetryExhausted {
retries: u32,
message: String,
},
#[error("Baseline already exists: {0}")]
AlreadyExistsError(String),
#[error("Fallback storage error: {0}")]
FallbackError(String),
#[error("No fallback storage available")]
NoFallbackAvailable,
#[error("I/O error: {0}")]
IoError(#[source] std::io::Error),
#[error("Invalid URL: {0}")]
UrlError(#[from] url::ParseError),
#[error("Request error: {0}")]
RequestError(#[source] reqwest::Error),
#[error("JSON error: {0}")]
SerializationError(#[from] serde_json::Error),
}
impl ClientError {
pub fn from_http(status: u16, body: &str) -> Self {
if let Ok(api_error) = serde_json::from_str::<ApiErrorResponse>(body) {
let code = api_error.error.code.clone();
let message = api_error.error.message;
match api_error.error.code.as_str() {
"UNAUTHORIZED" => ClientError::AuthError(message),
"FORBIDDEN" => ClientError::AuthError(message),
"NOT_FOUND" => ClientError::NotFoundError(message),
"VALIDATION_ERROR" => ClientError::ValidationError(message),
"ALREADY_EXISTS" => ClientError::AlreadyExistsError(message),
_ => ClientError::HttpError {
status,
message,
code: Some(code),
},
}
} else {
ClientError::HttpError {
status,
message: body.to_string(),
code: None,
}
}
}
pub fn is_connection_error(&self) -> bool {
match self {
ClientError::ConnectionError(_)
| ClientError::TimeoutError(_)
| ClientError::RetryExhausted { .. } => true,
ClientError::RequestError(e) => e.is_connect() || e.is_timeout(),
_ => false,
}
}
pub fn is_retryable(&self) -> bool {
match self {
ClientError::HttpError { status, .. } => {
*status >= 500 || *status == 429
}
ClientError::ConnectionError(_) => true,
ClientError::TimeoutError(_) => true,
ClientError::RequestError(e) => e.is_connect() || e.is_timeout(),
_ => false,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ApiErrorResponse {
pub error: ApiErrorBody,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ApiErrorBody {
pub code: String,
pub message: String,
#[serde(default)]
pub details: Option<serde_json::Value>,
#[serde(default)]
pub request_id: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_http_unauthorized() {
let body = r#"{"error":{"code":"UNAUTHORIZED","message":"Invalid API key"}}"#;
let error = ClientError::from_http(401, body);
assert!(matches!(error, ClientError::AuthError(_)));
}
#[test]
fn test_from_http_not_found() {
let body = r#"{"error":{"code":"NOT_FOUND","message":"Baseline not found"}}"#;
let error = ClientError::from_http(404, body);
assert!(matches!(error, ClientError::NotFoundError(_)));
}
#[test]
fn test_from_http_validation_error() {
let body = r#"{"error":{"code":"VALIDATION_ERROR","message":"Invalid benchmark name"}}"#;
let error = ClientError::from_http(400, body);
assert!(matches!(error, ClientError::ValidationError(_)));
}
#[test]
fn test_from_http_generic() {
let body = r#"{"error":{"code":"INTERNAL_ERROR","message":"Something went wrong"}}"#;
let error = ClientError::from_http(500, body);
assert!(matches!(error, ClientError::HttpError { .. }));
}
#[test]
fn test_from_http_malformed() {
let body = "Not JSON";
let error = ClientError::from_http(500, body);
assert!(matches!(
error,
ClientError::HttpError {
status: 500,
message: _,
code: None,
}
));
}
#[test]
fn test_is_connection_error() {
assert!(ClientError::ConnectionError("failed".to_string()).is_connection_error());
assert!(ClientError::TimeoutError(Duration::from_secs(30)).is_connection_error());
assert!(!ClientError::NotFoundError("not found".to_string()).is_connection_error());
}
#[test]
fn test_is_retryable() {
assert!(ClientError::from_http(500, "error").is_retryable());
assert!(ClientError::from_http(502, "error").is_retryable());
assert!(ClientError::from_http(503, "error").is_retryable());
assert!(ClientError::from_http(429, "rate limited").is_retryable());
assert!(!ClientError::from_http(400, "bad request").is_retryable());
assert!(!ClientError::from_http(404, "not found").is_retryable());
assert!(ClientError::ConnectionError("failed".to_string()).is_retryable());
}
}