pub type Result<T> = std::result::Result<T, ClientError>;
#[derive(thiserror::Error)]
pub enum ClientError {
#[error("VolumeLeaders API returned HTTP {code} for {url}")]
Status {
code: u16,
url: String,
body: String,
},
#[error("response body exceeded {limit} byte limit (got {actual} bytes)")]
BodyLimit {
limit: usize,
actual: usize,
},
#[error("unexpected content from {url}: expected {expected}")]
UnexpectedContent {
expected: String,
actual: String,
url: String,
},
#[error("session expired (redirected from {url})")]
SessionExpired {
url: String,
},
#[error("invalid session: {message}")]
SessionValidation {
message: String,
},
#[error("rate limited{}", retry_after.map(|s| format!(" (retry after {s}s)")).unwrap_or_default())]
RateLimit {
retry_after: Option<u64>,
},
#[error("cross-origin redirect blocked from {from}")]
CrossOriginRedirect {
from: String,
to: String,
},
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
impl std::fmt::Debug for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Status { code, url, .. } => f
.debug_struct("Status")
.field("code", code)
.field("url", url)
.field("body", &"[REDACTED BODY]")
.finish(),
Self::BodyLimit { limit, actual } => f
.debug_struct("BodyLimit")
.field("limit", limit)
.field("actual", actual)
.finish(),
Self::UnexpectedContent { expected, url, .. } => f
.debug_struct("UnexpectedContent")
.field("expected", expected)
.field("actual", &"[REDACTED]")
.field("url", url)
.finish(),
Self::SessionExpired { url } => {
f.debug_struct("SessionExpired").field("url", url).finish()
}
Self::SessionValidation { message } => f
.debug_struct("SessionValidation")
.field("message", message)
.finish(),
Self::RateLimit { retry_after } => f
.debug_struct("RateLimit")
.field("retry_after", retry_after)
.finish(),
Self::CrossOriginRedirect { from, .. } => f
.debug_struct("CrossOriginRedirect")
.field("from", from)
.field("to", &"[REDACTED REDIRECT]")
.finish(),
Self::Http(err) => f.debug_tuple("Http").field(err).finish(),
Self::Json(err) => f.debug_tuple("Json").field(err).finish(),
Self::Io(err) => f.debug_tuple("Io").field(err).finish(),
}
}
}
impl ClientError {
pub fn is_auth_error(&self) -> bool {
matches!(
self,
Self::SessionExpired { .. } | Self::SessionValidation { .. }
)
}
pub fn is_rate_limit(&self) -> bool {
matches!(self, Self::RateLimit { .. })
}
pub fn is_retryable(&self) -> bool {
matches!(self, Self::RateLimit { .. } | Self::Http(_))
}
pub fn is_session_expired(&self) -> bool {
matches!(self, Self::SessionExpired { .. })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_debug_redacts_body() {
let err = ClientError::Status {
code: 403,
url: "https://example.com/api".into(),
body: "secret account data with tokens and credentials".into(),
};
let debug = format!("{err:?}");
assert!(
debug.contains("[REDACTED BODY]"),
"Debug should contain [REDACTED BODY], got: {debug}"
);
assert!(
!debug.contains("secret account data"),
"Debug must not contain the actual body content"
);
}
#[test]
fn status_display_omits_body() {
let err = ClientError::Status {
code: 401,
url: "https://example.com/login".into(),
body: "sensitive auth response".into(),
};
let display = err.to_string();
assert_eq!(
display,
"VolumeLeaders API returned HTTP 401 for https://example.com/login"
);
assert!(!display.contains("sensitive auth response"));
}
#[test]
fn unexpected_content_debug_redacts_actual() {
let err = ClientError::UnexpectedContent {
expected: "application/json".into(),
actual: "partial auth token response data".into(),
url: "https://example.com/data".into(),
};
let debug = format!("{err:?}");
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("partial auth token"));
}
#[test]
fn cross_origin_redirect_debug_redacts_to() {
let err = ClientError::CrossOriginRedirect {
from: "https://volumeleaders.com/api".into(),
to: "https://evil.com/steal?session=abc123".into(),
};
let debug = format!("{err:?}");
assert!(debug.contains("[REDACTED REDIRECT]"));
assert!(!debug.contains("evil.com"));
assert!(!debug.contains("session=abc123"));
}
#[test]
fn is_auth_error_positive_cases() {
assert!(
ClientError::SessionExpired {
url: "https://example.com".into()
}
.is_auth_error()
);
assert!(
ClientError::SessionValidation {
message: "missing cookies".into()
}
.is_auth_error()
);
}
#[test]
fn is_auth_error_negative_cases() {
assert!(!ClientError::RateLimit { retry_after: None }.is_auth_error());
assert!(
!ClientError::BodyLimit {
limit: 1024,
actual: 2048
}
.is_auth_error()
);
}
#[test]
fn is_rate_limit_positive() {
assert!(
ClientError::RateLimit {
retry_after: Some(30)
}
.is_rate_limit()
);
assert!(ClientError::RateLimit { retry_after: None }.is_rate_limit());
}
#[test]
fn is_rate_limit_negative() {
assert!(
!ClientError::SessionExpired {
url: "https://example.com".into()
}
.is_rate_limit()
);
}
#[test]
fn is_retryable_covers_rate_limit_and_http() {
assert!(
ClientError::RateLimit {
retry_after: Some(5)
}
.is_retryable()
);
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
assert!(!ClientError::Io(io_err).is_retryable());
}
#[test]
fn is_session_expired_positive() {
assert!(
ClientError::SessionExpired {
url: "https://example.com".into()
}
.is_session_expired()
);
}
#[test]
fn is_session_expired_negative() {
assert!(
!ClientError::SessionValidation {
message: "missing".into()
}
.is_session_expired()
);
}
#[test]
fn debug_impl_covers_all_variants() {
let serde_err = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
let variants: Vec<ClientError> = vec![
ClientError::Status {
code: 500,
url: "https://example.com".into(),
body: "secret".into(),
},
ClientError::BodyLimit {
limit: 1024,
actual: 2048,
},
ClientError::UnexpectedContent {
expected: "json".into(),
actual: "html".into(),
url: "https://example.com".into(),
},
ClientError::SessionExpired {
url: "https://example.com".into(),
},
ClientError::SessionValidation {
message: "missing cookies".into(),
},
ClientError::RateLimit {
retry_after: Some(30),
},
ClientError::CrossOriginRedirect {
from: "https://a.com".into(),
to: "https://b.com".into(),
},
ClientError::Json(serde_err),
ClientError::Io(io_err),
];
for variant in &variants {
let debug = format!("{variant:?}");
assert!(!debug.is_empty(), "Debug output must not be empty");
}
let status_debug = format!("{:?}", variants[0]);
assert!(status_debug.contains("[REDACTED BODY]"));
assert!(!status_debug.contains("secret"));
let content_debug = format!("{:?}", variants[2]);
assert!(content_debug.contains("[REDACTED]"));
assert!(!content_debug.contains("html"));
let redirect_debug = format!("{:?}", variants[6]);
assert!(redirect_debug.contains("[REDACTED REDIRECT]"));
assert!(!redirect_debug.contains("b.com"));
}
}