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 the keystore is locked.
68    ///
69    /// This is the canonical check for lock state — callers should use this
70    /// method instead of inspecting the error message string.
71    pub fn is_keystore_locked(&self) -> bool {
72        matches!(self, Self::Keystore(msg) if msg.contains("locked"))
73    }
74
75    /// Returns `true` when the error indicates a credit, billing, or
76    /// quota-exhaustion problem that won't resolve by retrying quickly.
77    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        // These variants short-circuit in the match arm returning false
269        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        // Force a TOML serialization error using a custom serializer that always fails
316        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}