1use thiserror::Error;
2
3#[derive(Debug, Error)]
4pub enum RoboticusError {
5 #[error("config error: {0}")]
6 Config(String),
7
8 #[error("channel error: {0}")]
9 Channel(String),
10
11 #[error("database error: {0}")]
12 Database(String),
13
14 #[error("LLM error: {0}")]
15 Llm(String),
16
17 #[error("network error: {0}")]
18 Network(String),
19
20 #[error("policy violation: {rule} -- {reason}")]
21 Policy { rule: String, reason: String },
22
23 #[error("tool error: {tool} -- {message}")]
24 Tool { tool: String, message: String },
25
26 #[error("wallet error: {0}")]
27 Wallet(String),
28
29 #[error("injection detected: {0}")]
30 Injection(String),
31
32 #[error("schedule error: {0}")]
33 Schedule(String),
34
35 #[error("A2A error: {0}")]
36 A2a(String),
37
38 #[error("IO error: {0}")]
39 Io(#[from] std::io::Error),
40
41 #[error("skill error: {0}")]
42 Skill(String),
43
44 #[error("keystore error: {0}")]
45 Keystore(String),
46}
47
48impl From<toml::de::Error> for RoboticusError {
49 fn from(e: toml::de::Error) -> Self {
50 Self::Config(e.to_string())
51 }
52}
53
54impl From<toml::ser::Error> for RoboticusError {
55 fn from(e: toml::ser::Error) -> Self {
56 Self::Config(format!("TOML serialization error: {e}"))
57 }
58}
59
60impl From<serde_json::Error> for RoboticusError {
61 fn from(e: serde_json::Error) -> Self {
62 Self::Config(format!("JSON parse error: {e}"))
63 }
64}
65
66impl RoboticusError {
67 pub fn is_keystore_locked(&self) -> bool {
72 matches!(self, Self::Keystore(msg) if msg.contains("locked"))
73 }
74
75 pub fn is_credit_error(&self) -> bool {
78 let msg = match self {
79 Self::Llm(m) | Self::Network(m) => m,
80 _ => return false,
81 };
82 let lower = msg.to_ascii_lowercase();
83 lower.contains("402 payment required")
84 || (lower.contains("credit") && lower.contains("rate_limit"))
85 || (lower.contains("credit") && lower.contains("circuit breaker"))
86 || lower.contains("billing")
87 || lower.contains("insufficient_quota")
88 || lower.contains("exceeded your current quota")
89 }
90}
91
92pub type Result<T> = std::result::Result<T, RoboticusError>;
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
99 fn error_display_variants() {
100 let cases: Vec<(RoboticusError, &str)> = vec![
101 (
102 RoboticusError::Config("bad toml".into()),
103 "config error: bad toml",
104 ),
105 (
106 RoboticusError::Channel("serialize failed".into()),
107 "channel error: serialize failed",
108 ),
109 (
110 RoboticusError::Database("locked".into()),
111 "database error: locked",
112 ),
113 (RoboticusError::Llm("timeout".into()), "LLM error: timeout"),
114 (
115 RoboticusError::Network("refused".into()),
116 "network error: refused",
117 ),
118 (
119 RoboticusError::Policy {
120 rule: "financial".into(),
121 reason: "over limit".into(),
122 },
123 "policy violation: financial -- over limit",
124 ),
125 (
126 RoboticusError::Tool {
127 tool: "git".into(),
128 message: "not found".into(),
129 },
130 "tool error: git -- not found",
131 ),
132 (
133 RoboticusError::Wallet("no key".into()),
134 "wallet error: no key",
135 ),
136 (
137 RoboticusError::Injection("override attempt".into()),
138 "injection detected: override attempt",
139 ),
140 (
141 RoboticusError::Schedule("missed".into()),
142 "schedule error: missed",
143 ),
144 (
145 RoboticusError::A2a("handshake failed".into()),
146 "A2A error: handshake failed",
147 ),
148 (
149 RoboticusError::Skill("parse error".into()),
150 "skill error: parse error",
151 ),
152 (
153 RoboticusError::Keystore("locked".into()),
154 "keystore error: locked",
155 ),
156 ];
157
158 for (err, expected) in cases {
159 assert_eq!(err.to_string(), expected);
160 }
161 }
162
163 #[test]
164 fn io_error_conversion() {
165 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
166 let err: RoboticusError = io_err.into();
167 assert!(matches!(err, RoboticusError::Io(_)));
168 assert!(err.to_string().contains("missing"));
169 }
170
171 #[test]
172 fn toml_error_conversion() {
173 let bad_toml = "[[invalid";
174 let result: std::result::Result<toml::Value, _> = toml::from_str(bad_toml);
175 let err: RoboticusError = result.unwrap_err().into();
176 assert!(matches!(err, RoboticusError::Config(_)));
177 }
178
179 #[test]
180 fn json_error_conversion() {
181 let bad_json = "{invalid}";
182 let result: std::result::Result<serde_json::Value, _> = serde_json::from_str(bad_json);
183 let err: RoboticusError = result.unwrap_err().into();
184 assert!(matches!(err, RoboticusError::Config(_)));
185 }
186
187 #[test]
188 fn result_type_alias() {
189 let ok: Result<i32> = Ok(42);
190 assert!(matches!(ok, Ok(42)));
191
192 let err: Result<i32> = Err(RoboticusError::Config("test".into()));
193 assert!(err.is_err());
194 }
195
196 #[test]
197 fn is_keystore_locked_detects_locked_state() {
198 assert!(RoboticusError::Keystore("keystore is locked".into()).is_keystore_locked());
199 assert!(!RoboticusError::Keystore("decryption failed".into()).is_keystore_locked());
200 assert!(!RoboticusError::Config("locked".into()).is_keystore_locked());
201 }
202
203 #[test]
204 fn is_credit_error_detects_proxy_circuit_breaker() {
205 let err = RoboticusError::Llm(
206 r#"provider returned 429 Too Many Requests: {"error": {"message": "Rate limited — proxy circuit breaker for anthropic (credit)", "type": "rate_limit_error"}}"#.into(),
207 );
208 assert!(err.is_credit_error());
209 }
210
211 #[test]
212 fn is_credit_error_detects_402() {
213 let err = RoboticusError::Llm(
214 "provider returned 402 Payment Required: insufficient credits".into(),
215 );
216 assert!(err.is_credit_error());
217 }
218
219 #[test]
220 fn is_credit_error_detects_billing() {
221 let err = RoboticusError::Llm(
222 r#"provider returned 403: {"error": {"message": "Your billing account is inactive"}}"#
223 .into(),
224 );
225 assert!(err.is_credit_error());
226 }
227
228 #[test]
229 fn is_credit_error_detects_quota_exhaustion() {
230 let err = RoboticusError::Llm(
231 r#"provider returned 429: {"error": {"message": "You exceeded your current quota"}}"#
232 .into(),
233 );
234 assert!(err.is_credit_error());
235 }
236
237 #[test]
238 fn is_credit_error_detects_insufficient_quota() {
239 let err = RoboticusError::Llm(
240 r#"provider returned 429: {"error": {"type": "insufficient_quota"}}"#.into(),
241 );
242 assert!(err.is_credit_error());
243 }
244
245 #[test]
246 fn is_credit_error_false_for_transient_rate_limit() {
247 let err = RoboticusError::Llm(
248 "provider returned 429 Too Many Requests: rate limited, try again".into(),
249 );
250 assert!(!err.is_credit_error());
251 }
252
253 #[test]
254 fn is_credit_error_false_for_non_llm_variants() {
255 let err = RoboticusError::Config("credit billing".into());
256 assert!(!err.is_credit_error());
257 }
258
259 #[test]
260 fn is_credit_error_works_on_network_variant() {
261 let err =
262 RoboticusError::Network("provider returned 402 Payment Required: no credits".into());
263 assert!(err.is_credit_error());
264 }
265
266 #[test]
267 fn is_credit_error_false_for_other_variants() {
268 assert!(!RoboticusError::Database("credit billing".into()).is_credit_error());
270 assert!(!RoboticusError::Channel("credit rate_limit".into()).is_credit_error());
271 assert!(!RoboticusError::Wallet("billing issue".into()).is_credit_error());
272 assert!(!RoboticusError::Injection("402 Payment Required".into()).is_credit_error());
273 assert!(!RoboticusError::Schedule("billing".into()).is_credit_error());
274 assert!(!RoboticusError::A2a("billing".into()).is_credit_error());
275 assert!(!RoboticusError::Skill("billing".into()).is_credit_error());
276 assert!(!RoboticusError::Keystore("billing".into()).is_credit_error());
277 assert!(
278 !RoboticusError::Policy {
279 rule: "credit".into(),
280 reason: "billing".into()
281 }
282 .is_credit_error()
283 );
284 assert!(
285 !RoboticusError::Tool {
286 tool: "credit".into(),
287 message: "billing".into()
288 }
289 .is_credit_error()
290 );
291 let io_err = std::io::Error::other("billing");
292 assert!(!RoboticusError::Io(io_err).is_credit_error());
293 }
294
295 #[test]
296 fn is_credit_error_network_billing() {
297 let err = RoboticusError::Network("Your billing account is inactive".into());
298 assert!(err.is_credit_error());
299 }
300
301 #[test]
302 fn is_credit_error_credit_rate_limit_combo() {
303 let err = RoboticusError::Llm("credit exhausted, rate_limit triggered".into());
304 assert!(err.is_credit_error());
305 }
306
307 #[test]
308 fn is_credit_error_credit_circuit_breaker_combo() {
309 let err = RoboticusError::Llm("credit tripped circuit breaker".into());
310 assert!(err.is_credit_error());
311 }
312
313 #[test]
314 fn toml_ser_error_conversion() {
315 fn force_toml_ser_error<S: serde::Serializer>(
317 _v: &str,
318 _s: S,
319 ) -> std::result::Result<S::Ok, S::Error> {
320 Err(serde::ser::Error::custom("forced error"))
321 }
322 #[derive(serde::Serialize)]
323 struct Bad {
324 #[serde(serialize_with = "force_toml_ser_error")]
325 field: String,
326 }
327 let result = toml::to_string(&Bad { field: "x".into() });
328 let err: RoboticusError = result.unwrap_err().into();
329 assert!(matches!(err, RoboticusError::Config(_)));
330 assert!(err.to_string().contains("TOML serialization error"));
331 }
332}