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