Skip to content
Clone
e034b46
242 lines 7.0 KB Rust
use std::time::Duration;
#[derive(Debug, thiserror::Error)]
pub enum LlmError {
#[error("Agent error: {0}")]
Agent(String),
#[error("Provider error: {0}")]
Provider(String),
#[error("Token budget exceeded: used {used}, budget {budget}")]
TokenBudgetExceeded { used: usize, budget: usize },
#[error("Configuration error: {0}")]
Config(String),
#[error("Memory error: {0}")]
Memory(String),
#[error("Coordination error: {0}")]
Coordination(String),
#[error("Isolation error: {0}")]
Isolation(String),
#[error("Protocol error: {0}")]
Protocol(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Rate limited by {provider}. Retry after {retry_after_secs:?}s")]
RateLimited {
provider: String,
retry_after_secs: Option<u64>,
},
#[error("Context length exceeded: {tokens_used} tokens, limit {limit}")]
ContextLengthExceeded { tokens_used: usize, limit: usize },
#[error("Content filtered by {provider}: {reason}")]
ContentFiltered { provider: String, reason: String },
#[error("Authentication failed for {provider}")]
AuthError { provider: String },
#[error("Model not found: {model} on {provider}")]
ModelNotFound { provider: String, model: String },
#[error("Network error: {0}")]
Network(String),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("{provider} API error: {status} - {message}")]
ApiError {
provider: String,
status: u16,
message: String,
},
#[error("Streaming error: {0}")]
Stream(String),
#[error("Provider not found: {0}")]
ProviderNotFound(String),
#[error("Missing required parameter '{parameter}' for skill '{skill}'")]
SkillMissingParameter { skill: String, parameter: String },
#[error("Skill not found: {0}")]
SkillNotFound(String),
#[error("Skill rendering failed: {0}")]
SkillRenderFailed(String),
#[error("primary failed (retryable), then fallback also failed: primary={primary} -> fallback={fallback}")]
FallbackExhausted {
primary: Box<LlmError>,
fallback: Box<LlmError>,
},
}
impl LlmError {
pub fn is_retryable(&self) -> bool {
match self {
Self::RateLimited { .. } | Self::Network(_) => true,
Self::ApiError { status, .. } => *status == 429 || (500..600).contains(status),
Self::Io(e) => matches!(
e.kind(),
std::io::ErrorKind::TimedOut
| std::io::ErrorKind::Interrupted
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::BrokenPipe
),
Self::FallbackExhausted { .. } => false,
_ => false,
}
}
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::RateLimited {
retry_after_secs, ..
} => retry_after_secs.map(Duration::from_secs),
Self::FallbackExhausted { fallback, .. } => fallback.retry_after(),
_ => None,
}
}
pub fn provider(&self) -> Option<&str> {
match self {
Self::RateLimited { provider, .. }
| Self::ContentFiltered { provider, .. }
| Self::AuthError { provider }
| Self::ModelNotFound { provider, .. }
| Self::ApiError { provider, .. }
| Self::ProviderNotFound(provider) => Some(provider),
Self::FallbackExhausted { fallback, .. } => fallback.provider(),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_limit_and_transport_errors_are_retryable() {
assert!(
LlmError::RateLimited {
provider: "p".into(),
retry_after_secs: Some(3),
}
.is_retryable()
);
assert!(LlmError::Network("reset".into()).is_retryable());
}
#[test]
fn api_errors_retry_on_5xx_and_429_only() {
let err = |status| LlmError::ApiError {
provider: "p".into(),
status,
message: String::new(),
};
assert!(err(429).is_retryable());
assert!(err(500).is_retryable());
assert!(err(503).is_retryable());
assert!(!err(400).is_retryable());
assert!(!err(404).is_retryable());
}
#[test]
fn deterministic_failures_are_not_retryable() {
assert!(
!LlmError::ContextLengthExceeded {
tokens_used: 10,
limit: 5,
}
.is_retryable()
);
assert!(
!LlmError::AuthError {
provider: "p".into()
}
.is_retryable()
);
assert!(
!LlmError::ContentFiltered {
provider: "p".into(),
reason: "x".into(),
}
.is_retryable()
);
}
#[test]
fn retry_after_is_exposed_as_duration() {
let e = LlmError::RateLimited {
provider: "p".into(),
retry_after_secs: Some(30),
};
assert_eq!(e.retry_after(), Some(Duration::from_secs(30)));
assert_eq!(LlmError::Network("x".into()).retry_after(), None);
}
#[test]
fn provider_is_attributed_where_known() {
assert_eq!(
LlmError::AuthError {
provider: "openai".into()
}
.provider(),
Some("openai")
);
assert_eq!(LlmError::Stream("x".into()).provider(), None);
}
#[test]
fn fallback_exhausted_is_not_retryable() {
let err = LlmError::FallbackExhausted {
primary: Box::new(LlmError::RateLimited {
provider: "primary".into(),
retry_after_secs: Some(1),
}),
fallback: Box::new(LlmError::Network("down".into())),
};
assert!(!err.is_retryable());
}
#[test]
fn fallback_exhausted_passes_through_fallback_metadata() {
let err = LlmError::FallbackExhausted {
primary: Box::new(LlmError::RateLimited {
provider: "primary".into(),
retry_after_secs: Some(1),
}),
fallback: Box::new(LlmError::ApiError {
provider: "fb".into(),
status: 503,
message: "down".into(),
}),
};
assert_eq!(err.retry_after(), None);
assert_eq!(err.provider(), Some("fb"));
}
#[test]
fn fallback_exhausted_propagates_fallback_retry_after() {
let err = LlmError::FallbackExhausted {
primary: Box::new(LlmError::Network("reset".into())),
fallback: Box::new(LlmError::RateLimited {
provider: "fb".into(),
retry_after_secs: Some(7),
}),
};
assert_eq!(err.retry_after(), Some(Duration::from_secs(7)));
}
}