use reqwest::StatusCode;
use serde::Deserialize;
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("Authentication failed: {message}")]
Auth {
message: String,
details: Option<Box<ApiErrorResponse>>,
},
#[error("API error ({status}): {message}")]
Api {
status: StatusCode,
message: String,
response: Option<ApiErrorResponse>,
},
#[error("Resource not found: {resource}")]
NotFound {
resource: String,
},
#[error("Rate limit exceeded")]
RateLimited {
retry_after: Option<std::time::Duration>,
},
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
#[error("Failed to parse response: {message}")]
Parse {
message: String,
body: Option<String>,
},
#[cfg(feature = "websocket")]
#[error("WebSocket error: {0}")]
WebSocket(#[from] WsError),
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
}
#[cfg(feature = "websocket")]
#[derive(Debug, Error)]
pub enum WsError {
#[error("Connection failed: {0}")]
ConnectionFailed(String),
#[error("Connection closed: {reason}")]
Disconnected { reason: String },
#[error("Failed to send message: {0}")]
SendFailed(String),
#[error("Invalid message received: {0}")]
InvalidMessage(String),
#[error("Route is reserved for internal use: {0}")]
ReservedRoute(String),
#[error("Not connected to WebSocket server")]
NotConnected,
#[error("WebSocket authentication failed: {0}")]
AuthFailed(String),
}
#[derive(Debug, Clone, Deserialize)]
pub struct ApiErrorResponse {
#[serde(rename = "apiVersion")]
pub api_version: String,
pub error: ApiErrorDetails,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ApiErrorDetails {
#[serde(default)]
pub request: Option<Vec<String>>,
#[serde(default)]
pub inputs: Option<HashMap<String, Vec<String>>>,
}
impl ApiErrorResponse {
pub fn request_errors(&self) -> Vec<&str> {
self.error
.request
.as_ref()
.map(|v| v.iter().map(|s| s.as_str()).collect())
.unwrap_or_default()
}
pub fn field_errors(&self, field: &str) -> Vec<&str> {
self.error
.inputs
.as_ref()
.and_then(|m| m.get(field))
.map(|v| v.iter().map(|s| s.as_str()).collect())
.unwrap_or_default()
}
pub fn has_validation_errors(&self) -> bool {
self.error.inputs.as_ref().is_some_and(|m| !m.is_empty())
}
}
impl Error {
pub fn api_details(&self) -> Option<&ApiErrorResponse> {
match self {
Error::Auth { details, .. } => details.as_ref().map(|b| b.as_ref()),
Error::Api { response, .. } => response.as_ref(),
_ => None,
}
}
pub fn is_retryable(&self) -> bool {
matches!(self, Error::RateLimited { .. } | Error::Network(_))
}
pub fn is_auth_error(&self) -> bool {
matches!(self, Error::Auth { .. })
|| matches!(self, Error::Api { status, .. } if *status == StatusCode::UNAUTHORIZED)
}
pub(crate) fn auth(message: impl Into<String>) -> Self {
Error::Auth {
message: message.into(),
details: None,
}
}
pub(crate) fn auth_with_details(message: impl Into<String>, details: ApiErrorResponse) -> Self {
Error::Auth {
message: message.into(),
details: Some(Box::new(details)),
}
}
pub(crate) fn api(status: StatusCode, message: impl Into<String>) -> Self {
Error::Api {
status,
message: message.into(),
response: None,
}
}
pub(crate) fn api_with_response(
status: StatusCode,
message: impl Into<String>,
response: ApiErrorResponse,
) -> Self {
Error::Api {
status,
message: message.into(),
response: Some(response),
}
}
pub(crate) fn not_found(resource: impl Into<String>) -> Self {
Error::NotFound {
resource: resource.into(),
}
}
#[allow(dead_code)]
pub(crate) fn parse(message: impl Into<String>) -> Self {
Error::Parse {
message: message.into(),
body: None,
}
}
pub(crate) fn parse_with_body(message: impl Into<String>, body: String) -> Self {
Error::Parse {
message: message.into(),
body: Some(body),
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_is_retryable() {
assert!(Error::RateLimited { retry_after: None }.is_retryable());
assert!(!Error::auth("test").is_retryable());
assert!(!Error::not_found("item").is_retryable());
}
#[test]
fn test_error_is_auth_error() {
assert!(Error::auth("test").is_auth_error());
assert!(
Error::Api {
status: StatusCode::UNAUTHORIZED,
message: "test".into(),
response: None
}
.is_auth_error()
);
assert!(!Error::not_found("item").is_auth_error());
}
#[test]
fn test_api_error_response_helpers() {
let response = ApiErrorResponse {
api_version: "2.0".into(),
error: ApiErrorDetails {
request: Some(vec!["General error".into()]),
inputs: Some(HashMap::from([(
"email".into(),
vec!["Invalid email".into()],
)])),
},
};
assert_eq!(response.request_errors(), vec!["General error"]);
assert_eq!(response.field_errors("email"), vec!["Invalid email"]);
assert!(response.field_errors("password").is_empty());
assert!(response.has_validation_errors());
}
}