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, }, #[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, fallback: Box, }, } 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 { 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))); } }