Skip to main content

max / balanced_breakfast

9.7 KB · 276 lines History Blame Raw
1 //! Error types for the busser interface
2
3 use serde::{Deserialize, Serialize};
4
5 #[derive(Clone, Debug, Serialize, Deserialize)]
6 /// Errors that can occur in busser operations
7 pub enum BusserError {
8 /// Initialization failed
9 InitializationFailed { message: String },
10 /// Network/fetch error
11 FetchFailed { message: String },
12 /// Configuration error
13 ConfigError { message: String },
14 /// Authentication required or failed
15 AuthError { message: String },
16 /// Rate limited
17 RateLimited { retry_after_secs: u64 },
18 /// Invalid data received
19 ParseError { message: String },
20 /// Generic error
21 Other { message: String },
22 }
23
24 impl BusserError {
25 /// Create an initialization-failed error.
26 pub fn init_failed(msg: impl Into<String>) -> Self {
27 BusserError::InitializationFailed {
28 message: msg.into(),
29 }
30 }
31
32 /// Create a fetch-failed error.
33 pub fn fetch_failed(msg: impl Into<String>) -> Self {
34 BusserError::FetchFailed {
35 message: msg.into(),
36 }
37 }
38
39 /// Create a configuration error.
40 pub fn config_error(msg: impl Into<String>) -> Self {
41 BusserError::ConfigError {
42 message: msg.into(),
43 }
44 }
45
46 /// Create an authentication error.
47 pub fn auth_error(msg: impl Into<String>) -> Self {
48 BusserError::AuthError {
49 message: msg.into(),
50 }
51 }
52
53 /// Create a parse error for invalid data.
54 pub fn parse_error(msg: impl Into<String>) -> Self {
55 BusserError::ParseError {
56 message: msg.into(),
57 }
58 }
59
60 /// Create a generic error.
61 pub fn other(msg: impl Into<String>) -> Self {
62 BusserError::Other {
63 message: msg.into(),
64 }
65 }
66 }
67
68 impl std::fmt::Display for BusserError {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 BusserError::InitializationFailed { message } => {
72 write!(f, "Initialization failed: {}", message)
73 }
74 BusserError::FetchFailed { message } => write!(f, "Fetch failed: {}", message),
75 BusserError::ConfigError { message } => write!(f, "Config error: {}", message),
76 BusserError::AuthError { message } => write!(f, "Auth error: {}", message),
77 BusserError::RateLimited { retry_after_secs } => {
78 write!(f, "Rate limited, retry after {} seconds", retry_after_secs)
79 }
80 BusserError::ParseError { message } => write!(f, "Parse error: {}", message),
81 BusserError::Other { message } => write!(f, "Error: {}", message),
82 }
83 }
84 }
85
86 impl std::error::Error for BusserError {}
87
88 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
89 #[serde(rename_all = "snake_case")]
90 /// Broad error category determining retry behavior and circuit-breaker weight
91 pub enum ErrorCategory {
92 /// DNS timeout, 500, 502, 503 — retry normally, +1 failure weight.
93 Transient,
94 /// 429 or explicit retry-after — retry after delay, no failure penalty.
95 RateLimited,
96 /// 401, 403, invalid API key — no retry, immediate circuit break.
97 Auth,
98 /// Bad URL, missing field, 404 — no retry, immediate circuit break.
99 Config,
100 /// Malformed RSS/JSON — retry normally, +1 failure weight.
101 Parse,
102 /// Anything else — retry normally, +1 failure weight.
103 Unknown,
104 }
105
106 #[derive(Clone, Debug, Serialize, Deserialize)]
107 /// A structured error carrying category, display message, and optional retry-after hint
108 pub struct StructuredError {
109 pub category: ErrorCategory,
110 pub message: String,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub retry_after_secs: Option<u64>,
113 }
114
115 impl BusserError {
116 /// Map each variant to its default [`ErrorCategory`].
117 pub fn category(&self) -> ErrorCategory {
118 match self {
119 BusserError::InitializationFailed { .. } => ErrorCategory::Config,
120 BusserError::FetchFailed { .. } => ErrorCategory::Transient,
121 BusserError::ConfigError { .. } => ErrorCategory::Config,
122 BusserError::AuthError { .. } => ErrorCategory::Auth,
123 BusserError::RateLimited { .. } => ErrorCategory::RateLimited,
124 BusserError::ParseError { .. } => ErrorCategory::Parse,
125 BusserError::Other { .. } => ErrorCategory::Unknown,
126 }
127 }
128
129 /// Convert to a [`StructuredError`] using default category mapping.
130 pub fn to_structured(&self) -> StructuredError {
131 let retry_after_secs = match self {
132 BusserError::RateLimited { retry_after_secs } => Some(*retry_after_secs),
133 _ => None,
134 };
135 StructuredError {
136 category: self.category(),
137 message: self.to_string(),
138 retry_after_secs,
139 }
140 }
141 }
142
143 impl StructuredError {
144 /// Create a new structured error.
145 pub fn new(category: ErrorCategory, message: impl Into<String>) -> Self {
146 Self {
147 category,
148 message: message.into(),
149 retry_after_secs: None,
150 }
151 }
152
153 /// Create a rate-limited error with a retry-after hint.
154 pub fn rate_limited(message: impl Into<String>, retry_after_secs: u64) -> Self {
155 Self {
156 category: ErrorCategory::RateLimited,
157 message: message.into(),
158 retry_after_secs: Some(retry_after_secs),
159 }
160 }
161
162 /// Serialize to JSON for storage in `last_error`.
163 pub fn to_json(&self) -> String {
164 serde_json::to_string(self).unwrap_or_else(|_| self.message.clone())
165 }
166
167 /// Parse a `last_error` string. Tries JSON first, falls back to treating
168 /// the raw string as an `Unknown` category error (backward compat).
169 pub fn from_last_error(s: &str) -> Self {
170 serde_json::from_str(s).unwrap_or_else(|_| Self {
171 category: ErrorCategory::Unknown,
172 message: s.to_string(),
173 retry_after_secs: None,
174 })
175 }
176 }
177
178 impl std::fmt::Display for StructuredError {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 write!(f, "{}", self.message)
181 }
182 }
183
184 impl std::fmt::Display for ErrorCategory {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 match self {
187 ErrorCategory::Transient => write!(f, "transient"),
188 ErrorCategory::RateLimited => write!(f, "rate_limited"),
189 ErrorCategory::Auth => write!(f, "auth"),
190 ErrorCategory::Config => write!(f, "config"),
191 ErrorCategory::Parse => write!(f, "parse"),
192 ErrorCategory::Unknown => write!(f, "unknown"),
193 }
194 }
195 }
196
197 #[cfg(test)]
198 mod tests {
199 use super::*;
200
201 #[test]
202 fn display_init_failed() {
203 let e = BusserError::init_failed("bad config");
204 assert_eq!(e.to_string(), "Initialization failed: bad config");
205 }
206
207 #[test]
208 fn display_all_variants() {
209 assert!(BusserError::fetch_failed("timeout").to_string().contains("Fetch failed"));
210 assert!(BusserError::config_error("missing key").to_string().contains("Config error"));
211 assert!(BusserError::auth_error("expired").to_string().contains("Auth error"));
212 assert!(BusserError::parse_error("invalid json").to_string().contains("Parse error"));
213 assert!(BusserError::other("unknown").to_string().contains("Error"));
214 let rate = BusserError::RateLimited { retry_after_secs: 30 };
215 assert!(rate.to_string().contains("30"));
216 }
217
218 #[test]
219 fn category_mapping() {
220 assert_eq!(BusserError::init_failed("x").category(), ErrorCategory::Config);
221 assert_eq!(BusserError::fetch_failed("x").category(), ErrorCategory::Transient);
222 assert_eq!(BusserError::config_error("x").category(), ErrorCategory::Config);
223 assert_eq!(BusserError::auth_error("x").category(), ErrorCategory::Auth);
224 assert_eq!(BusserError::parse_error("x").category(), ErrorCategory::Parse);
225 assert_eq!(BusserError::other("x").category(), ErrorCategory::Unknown);
226 assert_eq!(
227 BusserError::RateLimited { retry_after_secs: 60 }.category(),
228 ErrorCategory::RateLimited
229 );
230 }
231
232 #[test]
233 fn to_structured_rate_limited() {
234 let e = BusserError::RateLimited { retry_after_secs: 60 };
235 let s = e.to_structured();
236 assert_eq!(s.category, ErrorCategory::RateLimited);
237 assert_eq!(s.retry_after_secs, Some(60));
238 }
239
240 #[test]
241 fn to_structured_regular() {
242 let s = BusserError::auth_error("bad key").to_structured();
243 assert_eq!(s.category, ErrorCategory::Auth);
244 assert!(s.retry_after_secs.is_none());
245 assert!(s.message.contains("bad key"));
246 }
247
248 #[test]
249 fn structured_error_json_roundtrip() {
250 let err = StructuredError::rate_limited("Too many requests", 120);
251 let json = err.to_json();
252 let parsed = StructuredError::from_last_error(&json);
253 assert_eq!(parsed.category, ErrorCategory::RateLimited);
254 assert_eq!(parsed.retry_after_secs, Some(120));
255 assert!(parsed.message.contains("Too many requests"));
256 }
257
258 #[test]
259 fn structured_error_from_legacy_plain_text() {
260 let parsed = StructuredError::from_last_error("HTTP request failed: timeout");
261 assert_eq!(parsed.category, ErrorCategory::Unknown);
262 assert_eq!(parsed.message, "HTTP request failed: timeout");
263 assert!(parsed.retry_after_secs.is_none());
264 }
265
266 #[test]
267 fn error_category_display() {
268 assert_eq!(ErrorCategory::Transient.to_string(), "transient");
269 assert_eq!(ErrorCategory::RateLimited.to_string(), "rate_limited");
270 assert_eq!(ErrorCategory::Auth.to_string(), "auth");
271 assert_eq!(ErrorCategory::Config.to_string(), "config");
272 assert_eq!(ErrorCategory::Parse.to_string(), "parse");
273 assert_eq!(ErrorCategory::Unknown.to_string(), "unknown");
274 }
275 }
276