use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum DecisionError {
#[error("decision model returned HTTP {status}: {body}")]
#[non_exhaustive]
Http {
status: u16,
body: String,
},
#[error("decision model rate limited or overloaded (HTTP {status}){}{}", .retry_after.map(|d| format!(", retry after {:.1}s", d.as_secs_f64())).unwrap_or_default(), if .body.is_empty() { String::new() } else { format!(": {}", .body) })]
#[non_exhaustive]
RateLimited {
status: u16,
retry_after: Option<Duration>,
body: String,
},
#[error("decision model call timed out after {:.1}s", .0.as_secs_f64())]
Timeout(Duration),
#[error("invalid decision request: {0}")]
Invalid(String),
#[error("unsupported by this decision backend: {0}")]
Unsupported(String),
#[error("decision model transport error: {message}")]
#[non_exhaustive]
Transport {
message: String,
#[source]
source: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
},
#[error("decision model API key missing: set {0}")]
MissingApiKey(String),
#[error("decision backend error: {message}")]
#[non_exhaustive]
Backend {
message: String,
#[source]
source: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
},
#[error("decision model returned an unusable response: {0}")]
BadResponse(String),
#[error("every decision model in the fallback chain failed: {}", fmt_attempts(.attempts))]
#[non_exhaustive]
AllFailed {
attempts: Vec<FallbackAttempt>,
},
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct FallbackAttempt {
model: String,
error: DecisionError,
sent: bool,
}
impl FallbackAttempt {
pub(crate) fn new(model: String, error: DecisionError, sent: bool) -> Self {
Self { model, error, sent }
}
pub fn model(&self) -> &str {
&self.model
}
pub fn error(&self) -> &DecisionError {
&self.error
}
pub fn was_sent(&self) -> bool {
self.sent
}
}
fn fmt_attempts(attempts: &[FallbackAttempt]) -> String {
attempts
.iter()
.map(|a| {
let skipped = if a.sent { "" } else { " (skipped)" };
format!("[{}{skipped}] {}", a.model, a.error)
})
.collect::<Vec<_>>()
.join("; ")
}
impl DecisionError {
pub fn is_retryable(&self) -> bool {
match self {
Self::RateLimited { .. } | Self::Transport { .. } => true,
Self::AllFailed { attempts } => attempts.iter().any(|a| a.error.is_retryable()),
_ => false,
}
}
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::RateLimited { retry_after, .. } => *retry_after,
_ => None,
}
}
pub fn http(status: u16, body: impl Into<String>) -> Self {
Self::Http {
status,
body: body.into(),
}
}
pub fn rate_limited(status: u16, retry_after: Option<Duration>) -> Self {
Self::rate_limited_with_body(status, retry_after, String::new())
}
pub fn rate_limited_with_body(
status: u16,
retry_after: Option<Duration>,
body: impl Into<String>,
) -> Self {
Self::RateLimited {
status,
retry_after,
body: body.into(),
}
}
pub fn transport(message: impl Into<String>) -> Self {
Self::Transport {
message: message.into(),
source: None,
}
}
pub fn transport_with_source(
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::Transport {
message: message.into(),
source: Some(Arc::new(source)),
}
}
pub fn backend(message: impl Into<String>) -> Self {
Self::Backend {
message: message.into(),
source: None,
}
}
pub fn backend_with_source(
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::Backend {
message: message.into(),
source: Some(Arc::new(source)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error;
#[test]
fn sources_are_wired() {
let io = std::io::Error::other("disk on fire");
let e = DecisionError::backend_with_source("lookup failed", io);
assert_eq!(e.to_string(), "decision backend error: lookup failed");
assert_eq!(e.source().unwrap().to_string(), "disk on fire");
assert!(DecisionError::backend("x").source().is_none());
let t = DecisionError::transport_with_source("connect", std::io::Error::other("refused"));
assert!(t.is_retryable());
assert_eq!(t.source().unwrap().to_string(), "refused");
assert!(t.clone().source().is_some());
}
}