use std::time::Duration;
use thiserror::Error;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RetryPolicy {
Idempotent,
Mutating,
}
#[derive(Debug, Error)]
pub enum ClientError {
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("unauthorized: {message}")]
Unauthorized { message: String },
#[error("rate limit exceeded: retry after {retry_after:?}")]
RateLimited { retry_after: Option<Duration> },
#[error("server error {status}: {message}")]
RetryableServer { status: u16, message: String },
#[error("api error: {0}")]
Api(String),
#[error("serde error: {0}")]
Serde(#[from] serde_json::Error),
}
impl ClientError {
pub fn is_unauthorized(&self) -> bool {
match self {
ClientError::Unauthorized { .. } => true,
ClientError::Http(e) => e.status().is_some_and(|s| s.as_u16() == 401),
_ => false,
}
}
pub fn is_retryable(&self) -> bool {
matches!(
self,
ClientError::Http(_)
| ClientError::RateLimited { .. }
| ClientError::RetryableServer { .. }
)
}
pub fn is_retryable_under(&self, policy: RetryPolicy) -> bool {
match policy {
RetryPolicy::Idempotent => self.is_retryable(),
RetryPolicy::Mutating => matches!(self, ClientError::RateLimited { .. }),
}
}
pub fn is_ambiguous_mutation(&self) -> bool {
matches!(
self,
ClientError::Http(_) | ClientError::RetryableServer { .. }
)
}
pub fn retry_after(&self) -> Option<Duration> {
match self {
ClientError::RateLimited { retry_after } => *retry_after,
_ => None,
}
}
pub fn class(&self) -> &'static str {
match self {
ClientError::Http(_) => "communication",
ClientError::Unauthorized { .. } => "unauthorized",
ClientError::RateLimited { .. } => "rate_limited",
ClientError::RetryableServer { .. } => "server",
ClientError::Api(_) => "api",
ClientError::Serde(_) => "serde",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn server_error() -> ClientError {
ClientError::RetryableServer {
status: 500,
message: "boom".into(),
}
}
fn rate_limited() -> ClientError {
ClientError::RateLimited {
retry_after: Some(Duration::from_secs(1)),
}
}
#[test]
fn idempotent_policy_retries_every_transient_error() {
for err in [
rate_limited(),
server_error(),
ClientError::RateLimited { retry_after: None },
] {
assert!(err.is_retryable_under(RetryPolicy::Idempotent));
}
}
#[test]
fn mutating_policy_only_retries_rate_limit() {
assert!(rate_limited().is_retryable_under(RetryPolicy::Mutating));
assert!(!server_error().is_retryable_under(RetryPolicy::Mutating));
assert!(!ClientError::Api("nope".into()).is_retryable_under(RetryPolicy::Mutating));
assert!(!ClientError::Api("nope".into()).is_retryable_under(RetryPolicy::Idempotent));
}
#[test]
fn ambiguous_mutation_flags_only_could_have_applied_failures() {
assert!(server_error().is_ambiguous_mutation());
assert!(!rate_limited().is_ambiguous_mutation());
assert!(!ClientError::Api("insufficient funds".into()).is_ambiguous_mutation());
}
}