use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RateLimitError {
pub retry_after: Option<u64>,
pub limit: Option<u64>,
pub remaining: Option<u64>,
pub reset: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientError {
pub message: String,
pub code: u16,
pub rate_limit: Option<RateLimitError>,
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(ref rl) = self.rate_limit {
write!(
f,
"ClientError (code {}): {} [rate_limit: retry_after={:?}, limit={:?}, remaining={:?}, reset={:?}]",
self.code, self.message, rl.retry_after, rl.limit, rl.remaining, rl.reset
)
} else {
write!(f, "ClientError (code {}): {}", self.code, self.message)
}
}
}
impl std::error::Error for ClientError {}
impl ClientError {
pub fn new(code: u16, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
rate_limit: None,
}
}
pub fn with_rate_limit(
code: u16,
message: impl Into<String>,
rate_limit: RateLimitError,
) -> Self {
Self {
code,
message: message.into(),
rate_limit: Some(rate_limit),
}
}
pub fn is_auth(&self) -> bool {
self.code == 401 || self.code == 403
}
pub fn is_rate_limited(&self) -> bool {
self.code == 429
}
pub fn is_not_found(&self) -> bool {
self.code == 404
}
pub fn is_server_error(&self) -> bool {
(500..600).contains(&self.code)
}
pub fn is_retryable(&self) -> bool {
self.is_rate_limited()
|| self.is_server_error()
|| (self.code == 0
&& (self.message.to_ascii_lowercase().contains("timed out")
|| self.message.to_ascii_lowercase().contains("timeout")
|| self.message.to_ascii_lowercase().contains("connection reset")
|| self.message.to_ascii_lowercase().contains("network stream error")))
}
}
pub fn parse_retry_after(s: &str) -> Option<u64> {
let trimmed = s.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(secs) = trimmed.parse::<u64>() {
return Some(secs);
}
if let Ok(system_time) = httpdate::parse_http_date(trimmed) {
let now = std::time::SystemTime::now();
let secs = system_time
.duration_since(now)
.map(|d| d.as_secs())
.unwrap_or(0);
return Some(secs);
}
None
}
pub fn extract_rate_limit_headers(headers: &reqwest::header::HeaderMap) -> Option<RateLimitError> {
let parse_u64 = |keys: &[&str]| -> Option<u64> {
for &k in keys {
if let Some(val) = headers.get(k) {
if let Ok(s) = val.to_str() {
let trimmed = s.trim();
if !trimmed.is_empty() {
if let Ok(n) = trimmed.parse::<u64>() {
return Some(n);
}
}
}
}
}
None
};
let parse_retry_after_from_headers = |keys: &[&str]| -> Option<u64> {
for &k in keys {
if let Some(val) = headers.get(k) {
if let Ok(s) = val.to_str() {
if let Some(secs) = parse_retry_after(s) {
return Some(secs);
}
}
}
}
None
};
let retry_after = parse_retry_after_from_headers(&["retry-after", "x-retry-after"]);
let limit = parse_u64(&["ratelimit-limit", "x-ratelimit-limit", "x-rate-limit-limit"]);
let remaining = parse_u64(&["ratelimit-remaining", "x-ratelimit-remaining", "x-rate-limit-remaining"]);
let reset = parse_u64(&["ratelimit-reset", "x-ratelimit-reset", "x-rate-limit-reset"]);
if retry_after.is_some() || limit.is_some() || remaining.is_some() || reset.is_some() {
Some(RateLimitError {
retry_after,
limit,
remaining,
reset,
})
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_error_display() {
let err = ClientError::new(404, "Not Found");
assert_eq!(format!("{}", err), "ClientError (code 404): Not Found");
}
#[test]
fn test_client_error_debug() {
let err = ClientError::new(500, "Internal Error");
let debug_str = format!("{:?}", err);
assert!(debug_str.contains("500"));
assert!(debug_str.contains("Internal Error"));
}
#[test]
fn test_client_error_classification_methods() {
let auth_err = ClientError::new(401, "Unauthorized");
assert!(auth_err.is_auth());
assert!(!auth_err.is_server_error());
assert!(!auth_err.is_retryable());
let forbidden_err = ClientError::new(403, "Forbidden");
assert!(forbidden_err.is_auth());
let rate_err = ClientError::new(429, "Too Many Requests");
assert!(rate_err.is_rate_limited());
assert!(rate_err.is_retryable());
let server_err = ClientError::new(503, "Service Unavailable");
assert!(server_err.is_server_error());
assert!(server_err.is_retryable());
let timeout_err = ClientError::new(0, "operation timed out after 30s");
assert!(timeout_err.is_retryable());
}
#[test]
fn test_client_error_clone_and_eq() {
let err1 = ClientError::new(400, "Bad Request");
let err2 = err1.clone();
assert_eq!(err1, err2);
assert_eq!(err1.code, 400);
assert_eq!(err1.message, "Bad Request");
let err3 = ClientError::new(401, "Unauthorized");
assert_ne!(err1, err3);
}
#[test]
fn test_client_error_implements_std_error() {
let err: Box<dyn std::error::Error> = Box::new(ClientError::new(403, "Forbidden"));
assert_eq!(format!("{}", err), "ClientError (code 403): Forbidden");
assert!(err.source().is_none());
}
#[test]
fn test_extract_rate_limit_headers() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("Retry-After", "60".parse().unwrap());
headers.insert("RateLimit-Limit", "1000".parse().unwrap());
headers.insert("RateLimit-Remaining", "5".parse().unwrap());
headers.insert("RateLimit-Reset", "1700000000".parse().unwrap());
let rl = extract_rate_limit_headers(&headers).expect("should extract rate limit headers");
assert_eq!(rl.retry_after, Some(60));
assert_eq!(rl.limit, Some(1000));
assert_eq!(rl.remaining, Some(5));
assert_eq!(rl.reset, Some(1700000000));
let err_with_rl = ClientError::with_rate_limit(429, "Rate limit exceeded", rl);
assert!(err_with_rl.is_rate_limited());
assert!(err_with_rl.rate_limit.is_some());
assert_eq!(err_with_rl.rate_limit.as_ref().unwrap().retry_after, Some(60));
assert!(format!("{}", err_with_rl).contains("[rate_limit: retry_after=Some(60)"));
}
#[test]
fn test_parse_retry_after_http_date() {
assert_eq!(parse_retry_after("120"), Some(120));
assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), Some(0));
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("Retry-After", "Wed, 21 Oct 2015 07:28:00 GMT".parse().unwrap());
let rl = extract_rate_limit_headers(&headers).expect("should parse HTTP-date Retry-After");
assert_eq!(rl.retry_after, Some(0));
}
}