use std::time::Duration;
#[derive(Debug, Clone, thiserror::Error)]
pub enum ClassifiedError {
#[error("rate limited by {provider}, retry after {retry_after:?}")]
RateLimited {
provider: String,
retry_after: Option<Duration>,
},
#[error("provider unavailable: {provider} - {reason}")]
ProviderUnavailable { provider: String, reason: String },
#[error("auth failed: {provider}")]
AuthFailed { provider: String },
#[error("context overflow: {tokens} tokens")]
ContextOverflow { tokens: u32 },
#[error("output truncated at max_tokens")]
OutputTruncated,
#[error("stream timeout: no chunk for {0:?}")]
StreamTimeout(Duration),
#[error("tool error: {tool} - {message}")]
ToolError { tool: String, message: String },
#[error("tool not found: {0}")]
ToolNotFound(String),
#[error("budget exhausted after {0} turns")]
BudgetExhausted(u32),
#[error("io: {0}")]
Io(String),
#[error("http: {0}")]
Http(String),
#[error("parse: {0}")]
Parse(String),
#[error("config: {0}")]
Config(String),
#[error("session: {0}")]
Session(String),
#[error("storage: {0}")]
Storage(String),
#[error("runtime: {0}")]
Runtime(String),
#[error("internal: {0}")]
Internal(String),
}
impl ClassifiedError {
#[must_use]
pub fn variant_name(&self) -> &'static str {
match self {
Self::RateLimited { .. } => "RateLimited",
Self::ProviderUnavailable { .. } => "ProviderUnavailable",
Self::AuthFailed { .. } => "AuthFailed",
Self::ContextOverflow { .. } => "ContextOverflow",
Self::OutputTruncated => "OutputTruncated",
Self::StreamTimeout(_) => "StreamTimeout",
Self::ToolError { .. } => "ToolError",
Self::ToolNotFound(_) => "ToolNotFound",
Self::BudgetExhausted(_) => "BudgetExhausted",
Self::Io(_) => "Io",
Self::Http(_) => "Http",
Self::Parse(_) => "Parse",
Self::Config(_) => "Config",
Self::Session(_) => "Session",
Self::Storage(_) => "Storage",
Self::Runtime(_) => "Runtime",
Self::Internal(_) => "Internal",
}
}
#[must_use]
pub fn from_tagged(kind: &str, msg: &str, retry_after_secs: Option<u64>) -> Self {
match kind {
"RateLimited" => Self::RateLimited {
provider: "unknown".into(),
retry_after: retry_after_secs.map(Duration::from_secs),
},
"ProviderUnavailable" => Self::ProviderUnavailable {
provider: "unknown".into(),
reason: msg.into(),
},
"AuthFailed" => Self::AuthFailed {
provider: "unknown".into(),
},
"ContextOverflow" => Self::ContextOverflow { tokens: 0 },
"OutputTruncated" => Self::OutputTruncated,
"StreamTimeout" => Self::StreamTimeout(Duration::from_secs(0)),
"ToolError" => Self::ToolError {
tool: "unknown".into(),
message: msg.into(),
},
"ToolNotFound" => Self::ToolNotFound(msg.into()),
"BudgetExhausted" => Self::BudgetExhausted(0),
"Io" => Self::Io(msg.into()),
"Http" => Self::Http(msg.into()),
"Parse" => Self::Parse(msg.into()),
"Config" => Self::Config(msg.into()),
"Session" => Self::Session(msg.into()),
"Storage" => Self::Storage(msg.into()),
"Runtime" => Self::Runtime(msg.into()),
"Internal" => Self::Internal(msg.into()),
_ => Self::Runtime(format!("{kind}: {msg}")),
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::RateLimited { .. }
| Self::ProviderUnavailable { .. }
| Self::StreamTimeout(_)
| Self::Http(_)
)
}
#[must_use]
pub fn recovery_hint(&self) -> RecoveryAction {
match self {
Self::RateLimited { retry_after, .. } => {
RecoveryAction::RetryAfter(retry_after.unwrap_or_else(|| Duration::from_secs(5)))
}
Self::ProviderUnavailable { .. } | Self::StreamTimeout(_) | Self::Http(_) => {
RecoveryAction::TryNextProvider
}
Self::Io(_) => RecoveryAction::Fail,
Self::AuthFailed { .. } => RecoveryAction::TryNextCredential,
Self::ContextOverflow { .. } => RecoveryAction::CompressHistory,
Self::OutputTruncated => RecoveryAction::IncreaseMaxTokens,
Self::BudgetExhausted(_) => RecoveryAction::RequestMoreBudget,
Self::ToolError { .. }
| Self::ToolNotFound(_)
| Self::Parse(_)
| Self::Config(_)
| Self::Session(_)
| Self::Storage(_)
| Self::Runtime(_)
| Self::Internal(_) => RecoveryAction::Fail,
}
}
}
impl From<std::io::Error> for ClassifiedError {
fn from(err: std::io::Error) -> Self {
Self::Io(err.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecoveryAction {
RetryAfter(Duration),
TryNextCredential,
TryNextProvider,
CompressHistory,
IncreaseMaxTokens,
RequestMoreBudget,
Fail,
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn rate_limited_is_retryable_with_retry_after() {
let err = ClassifiedError::RateLimited {
provider: "openai".into(),
retry_after: Some(Duration::from_secs(3)),
};
assert!(err.is_retryable());
assert_eq!(
err.recovery_hint(),
RecoveryAction::RetryAfter(Duration::from_secs(3))
);
}
#[test]
fn rate_limited_defaults_to_5s_without_retry_after() {
let err = ClassifiedError::RateLimited {
provider: "openai".into(),
retry_after: None,
};
assert_eq!(
err.recovery_hint(),
RecoveryAction::RetryAfter(Duration::from_secs(5))
);
}
#[test]
fn auth_failed_rotates_credential() {
let err = ClassifiedError::AuthFailed {
provider: "claude".into(),
};
assert!(!err.is_retryable());
assert_eq!(err.recovery_hint(), RecoveryAction::TryNextCredential);
}
#[test]
fn provider_unavailable_falls_over() {
let err = ClassifiedError::ProviderUnavailable {
provider: "x".into(),
reason: "connection reset".into(),
};
assert!(err.is_retryable());
assert_eq!(err.recovery_hint(), RecoveryAction::TryNextProvider);
}
#[test]
fn context_overflow_compresses_history() {
let err = ClassifiedError::ContextOverflow { tokens: 40_000 };
assert!(!err.is_retryable());
assert_eq!(err.recovery_hint(), RecoveryAction::CompressHistory);
}
#[test]
fn tool_errors_are_fatal_to_the_call_site() {
let err = ClassifiedError::ToolError {
tool: "file_read".into(),
message: "permission denied".into(),
};
assert!(!err.is_retryable());
assert_eq!(err.recovery_hint(), RecoveryAction::Fail);
}
#[test]
fn stream_timeout_falls_over() {
let err = ClassifiedError::StreamTimeout(Duration::from_secs(90));
assert!(err.is_retryable());
assert_eq!(err.recovery_hint(), RecoveryAction::TryNextProvider);
}
#[test]
fn budget_exhausted_requests_more() {
let err = ClassifiedError::BudgetExhausted(100);
assert_eq!(err.recovery_hint(), RecoveryAction::RequestMoreBudget);
}
}