Skip to main content

roboticus_core/
error.rs

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    /// Returns `true` when the error indicates a credit, billing, or
68    /// quota-exhaustion problem that won't resolve by retrying quickly.
69    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        // These variants short-circuit in the match arm returning false
254        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        // Force a TOML serialization error using a custom serializer that always fails
301        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}