use std::sync::Arc;
use std::time::Duration;
use jiff::Timestamp;
use crate::id::IdError;
pub type Result<T, E = Error> = std::result::Result<T, E>;
pub type Source = Arc<dyn std::error::Error + Send + Sync>;
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("rate limited{}", retry_hint(.retry_after))]
RateLimited { retry_after: Option<Duration> },
#[error("usage quota exhausted{}", reset_hint(.resets_at))]
QuotaExhausted { resets_at: Option<Timestamp> },
#[error("network error: {0}")]
Network(#[source] Source),
#[error("request timed out")]
Timeout,
#[error("server error (HTTP {status}){}", message_hint(.message))]
Server {
status: u16,
message: Option<String>,
},
#[error("provider temporarily disabled after repeated failures")]
CircuitOpen { until: Option<Timestamp> },
#[error("authentication rejected{}", message_hint(.message))]
AuthRejected { message: Option<String> },
#[error("not found")]
NotFound,
#[error("bad request: {0}")]
BadRequest(String),
#[error("unsupported: {0}")]
Unsupported(&'static str),
#[error("could not decode response: {0}")]
Decode(#[source] Source),
#[error(transparent)]
InvalidId(#[from] IdError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ErrorCategory {
Unavailable,
Throttled,
Auth,
NotFound,
Usage,
Unsupported,
Protocol,
}
impl Error {
pub fn network(e: impl std::error::Error + Send + Sync + 'static) -> Self {
Error::Network(Arc::new(e))
}
pub fn decode(e: impl std::error::Error + Send + Sync + 'static) -> Self {
Error::Decode(Arc::new(e))
}
pub fn is_retryable(&self) -> bool {
matches!(
self,
Error::RateLimited { .. } | Error::Network(_) | Error::Timeout | Error::Server { .. }
)
}
pub fn trips_breaker(&self) -> bool {
matches!(
self,
Error::Network(_) | Error::Timeout | Error::Server { .. }
)
}
pub fn category(&self) -> ErrorCategory {
match self {
Error::Network(_)
| Error::Timeout
| Error::Server { .. }
| Error::CircuitOpen { .. } => ErrorCategory::Unavailable,
Error::RateLimited { .. } | Error::QuotaExhausted { .. } => ErrorCategory::Throttled,
Error::AuthRejected { .. } => ErrorCategory::Auth,
Error::NotFound => ErrorCategory::NotFound,
Error::BadRequest(_) | Error::InvalidId(_) => ErrorCategory::Usage,
Error::Unsupported(_) => ErrorCategory::Unsupported,
Error::Decode(_) => ErrorCategory::Protocol,
}
}
}
fn retry_hint(d: &Option<Duration>) -> String {
d.map(|d| format!(" (retry after {}s)", d.as_secs()))
.unwrap_or_default()
}
fn reset_hint(t: &Option<Timestamp>) -> String {
t.map(|t| format!(" (resets at {t})")).unwrap_or_default()
}
fn message_hint(m: &Option<String>) -> String {
m.as_ref().map(|m| format!(": {m}")).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_limits_retry_but_do_not_trip_the_breaker() {
let e = Error::RateLimited { retry_after: None };
assert!(e.is_retryable());
assert!(!e.trips_breaker());
assert_eq!(e.category(), ErrorCategory::Throttled);
}
#[test]
fn quota_and_auth_are_not_retryable() {
assert!(!Error::QuotaExhausted { resets_at: None }.is_retryable());
assert!(!Error::AuthRejected { message: None }.is_retryable());
}
#[test]
fn server_errors_retry_and_trip() {
let e = Error::Server {
status: 503,
message: Some("Service Unavailable".into()),
};
assert!(e.is_retryable() && e.trips_breaker());
assert_eq!(
e.to_string(),
"server error (HTTP 503): Service Unavailable"
);
}
#[test]
fn errors_are_cloneable_with_sources() {
let e = Error::network(std::io::Error::other("reset"));
let shared = e.clone();
assert_eq!(shared.to_string(), "network error: reset");
assert!(std::error::Error::source(&shared).is_some());
}
}