//! Error types for the busser interface use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize)] /// Errors that can occur in busser operations pub enum BusserError { /// Initialization failed InitializationFailed { message: String }, /// Network/fetch error FetchFailed { message: String }, /// Configuration error ConfigError { message: String }, /// Authentication required or failed AuthError { message: String }, /// Rate limited RateLimited { retry_after_secs: u64 }, /// Invalid data received ParseError { message: String }, /// Generic error Other { message: String }, } impl BusserError { /// Create an initialization-failed error. pub fn init_failed(msg: impl Into) -> Self { BusserError::InitializationFailed { message: msg.into(), } } /// Create a fetch-failed error. pub fn fetch_failed(msg: impl Into) -> Self { BusserError::FetchFailed { message: msg.into(), } } /// Create a configuration error. pub fn config_error(msg: impl Into) -> Self { BusserError::ConfigError { message: msg.into(), } } /// Create an authentication error. pub fn auth_error(msg: impl Into) -> Self { BusserError::AuthError { message: msg.into(), } } /// Create a parse error for invalid data. pub fn parse_error(msg: impl Into) -> Self { BusserError::ParseError { message: msg.into(), } } /// Create a generic error. pub fn other(msg: impl Into) -> Self { BusserError::Other { message: msg.into(), } } } impl std::fmt::Display for BusserError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { BusserError::InitializationFailed { message } => { write!(f, "Initialization failed: {}", message) } BusserError::FetchFailed { message } => write!(f, "Fetch failed: {}", message), BusserError::ConfigError { message } => write!(f, "Config error: {}", message), BusserError::AuthError { message } => write!(f, "Auth error: {}", message), BusserError::RateLimited { retry_after_secs } => { write!(f, "Rate limited, retry after {} seconds", retry_after_secs) } BusserError::ParseError { message } => write!(f, "Parse error: {}", message), BusserError::Other { message } => write!(f, "Error: {}", message), } } } impl std::error::Error for BusserError {} #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] /// Broad error category determining retry behavior and circuit-breaker weight pub enum ErrorCategory { /// DNS timeout, 500, 502, 503 — retry normally, +1 failure weight. Transient, /// 429 or explicit retry-after — retry after delay, no failure penalty. RateLimited, /// 401, 403, invalid API key — no retry, immediate circuit break. Auth, /// Bad URL, missing field, 404 — no retry, immediate circuit break. Config, /// Malformed RSS/JSON — retry normally, +1 failure weight. Parse, /// Anything else — retry normally, +1 failure weight. Unknown, } #[derive(Clone, Debug, Serialize, Deserialize)] /// A structured error carrying category, display message, and optional retry-after hint pub struct StructuredError { pub category: ErrorCategory, pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub retry_after_secs: Option, } impl BusserError { /// Map each variant to its default [`ErrorCategory`]. pub fn category(&self) -> ErrorCategory { match self { BusserError::InitializationFailed { .. } => ErrorCategory::Config, BusserError::FetchFailed { .. } => ErrorCategory::Transient, BusserError::ConfigError { .. } => ErrorCategory::Config, BusserError::AuthError { .. } => ErrorCategory::Auth, BusserError::RateLimited { .. } => ErrorCategory::RateLimited, BusserError::ParseError { .. } => ErrorCategory::Parse, BusserError::Other { .. } => ErrorCategory::Unknown, } } /// Convert to a [`StructuredError`] using default category mapping. pub fn to_structured(&self) -> StructuredError { let retry_after_secs = match self { BusserError::RateLimited { retry_after_secs } => Some(*retry_after_secs), _ => None, }; StructuredError { category: self.category(), message: self.to_string(), retry_after_secs, } } } impl StructuredError { /// Create a new structured error. pub fn new(category: ErrorCategory, message: impl Into) -> Self { Self { category, message: message.into(), retry_after_secs: None, } } /// Create a rate-limited error with a retry-after hint. pub fn rate_limited(message: impl Into, retry_after_secs: u64) -> Self { Self { category: ErrorCategory::RateLimited, message: message.into(), retry_after_secs: Some(retry_after_secs), } } /// Serialize to JSON for storage in `last_error`. pub fn to_json(&self) -> String { serde_json::to_string(self).unwrap_or_else(|_| self.message.clone()) } /// Parse a `last_error` string. Tries JSON first, falls back to treating /// the raw string as an `Unknown` category error (backward compat). pub fn from_last_error(s: &str) -> Self { serde_json::from_str(s).unwrap_or_else(|_| Self { category: ErrorCategory::Unknown, message: s.to_string(), retry_after_secs: None, }) } } impl std::fmt::Display for StructuredError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.message) } } impl std::fmt::Display for ErrorCategory { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ErrorCategory::Transient => write!(f, "transient"), ErrorCategory::RateLimited => write!(f, "rate_limited"), ErrorCategory::Auth => write!(f, "auth"), ErrorCategory::Config => write!(f, "config"), ErrorCategory::Parse => write!(f, "parse"), ErrorCategory::Unknown => write!(f, "unknown"), } } } #[cfg(test)] mod tests { use super::*; #[test] fn display_init_failed() { let e = BusserError::init_failed("bad config"); assert_eq!(e.to_string(), "Initialization failed: bad config"); } #[test] fn display_all_variants() { assert!(BusserError::fetch_failed("timeout").to_string().contains("Fetch failed")); assert!(BusserError::config_error("missing key").to_string().contains("Config error")); assert!(BusserError::auth_error("expired").to_string().contains("Auth error")); assert!(BusserError::parse_error("invalid json").to_string().contains("Parse error")); assert!(BusserError::other("unknown").to_string().contains("Error")); let rate = BusserError::RateLimited { retry_after_secs: 30 }; assert!(rate.to_string().contains("30")); } #[test] fn category_mapping() { assert_eq!(BusserError::init_failed("x").category(), ErrorCategory::Config); assert_eq!(BusserError::fetch_failed("x").category(), ErrorCategory::Transient); assert_eq!(BusserError::config_error("x").category(), ErrorCategory::Config); assert_eq!(BusserError::auth_error("x").category(), ErrorCategory::Auth); assert_eq!(BusserError::parse_error("x").category(), ErrorCategory::Parse); assert_eq!(BusserError::other("x").category(), ErrorCategory::Unknown); assert_eq!( BusserError::RateLimited { retry_after_secs: 60 }.category(), ErrorCategory::RateLimited ); } #[test] fn to_structured_rate_limited() { let e = BusserError::RateLimited { retry_after_secs: 60 }; let s = e.to_structured(); assert_eq!(s.category, ErrorCategory::RateLimited); assert_eq!(s.retry_after_secs, Some(60)); } #[test] fn to_structured_regular() { let s = BusserError::auth_error("bad key").to_structured(); assert_eq!(s.category, ErrorCategory::Auth); assert!(s.retry_after_secs.is_none()); assert!(s.message.contains("bad key")); } #[test] fn structured_error_json_roundtrip() { let err = StructuredError::rate_limited("Too many requests", 120); let json = err.to_json(); let parsed = StructuredError::from_last_error(&json); assert_eq!(parsed.category, ErrorCategory::RateLimited); assert_eq!(parsed.retry_after_secs, Some(120)); assert!(parsed.message.contains("Too many requests")); } #[test] fn structured_error_from_legacy_plain_text() { let parsed = StructuredError::from_last_error("HTTP request failed: timeout"); assert_eq!(parsed.category, ErrorCategory::Unknown); assert_eq!(parsed.message, "HTTP request failed: timeout"); assert!(parsed.retry_after_secs.is_none()); } #[test] fn error_category_display() { assert_eq!(ErrorCategory::Transient.to_string(), "transient"); assert_eq!(ErrorCategory::RateLimited.to_string(), "rate_limited"); assert_eq!(ErrorCategory::Auth.to_string(), "auth"); assert_eq!(ErrorCategory::Config.to_string(), "config"); assert_eq!(ErrorCategory::Parse.to_string(), "parse"); assert_eq!(ErrorCategory::Unknown.to_string(), "unknown"); } }