use thiserror::Error;
#[derive(Debug, Error)]
pub enum RoboticusError {
#[error("config error: {0}")]
Config(String),
#[error("channel error: {0}")]
Channel(String),
#[error("database error: {0}")]
Database(String),
#[error("LLM error: {0}")]
Llm(String),
#[error("network error: {0}")]
Network(String),
#[error("policy violation: {rule} -- {reason}")]
Policy { rule: String, reason: String },
#[error("tool error: {tool} -- {message}")]
Tool { tool: String, message: String },
#[error("wallet error: {0}")]
Wallet(String),
#[error("injection detected: {0}")]
Injection(String),
#[error("schedule error: {0}")]
Schedule(String),
#[error("A2A error: {0}")]
A2a(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("skill error: {0}")]
Skill(String),
#[error("keystore error: {0}")]
Keystore(String),
}
impl From<toml::de::Error> for RoboticusError {
fn from(e: toml::de::Error) -> Self {
Self::Config(e.to_string())
}
}
impl From<toml::ser::Error> for RoboticusError {
fn from(e: toml::ser::Error) -> Self {
Self::Config(format!("TOML serialization error: {e}"))
}
}
impl From<serde_json::Error> for RoboticusError {
fn from(e: serde_json::Error) -> Self {
Self::Config(format!("JSON parse error: {e}"))
}
}
impl RoboticusError {
pub fn is_keystore_locked(&self) -> bool {
matches!(self, Self::Keystore(msg) if msg.contains("locked"))
}
pub fn is_credit_error(&self) -> bool {
let msg = match self {
Self::Llm(m) | Self::Network(m) => m,
_ => return false,
};
let lower = msg.to_ascii_lowercase();
lower.contains("402 payment required")
|| (lower.contains("credit") && lower.contains("rate_limit"))
|| (lower.contains("credit") && lower.contains("circuit breaker"))
|| lower.contains("billing")
|| lower.contains("insufficient_quota")
|| lower.contains("exceeded your current quota")
}
}
pub type Result<T> = std::result::Result<T, RoboticusError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_display_variants() {
let cases: Vec<(RoboticusError, &str)> = vec![
(
RoboticusError::Config("bad toml".into()),
"config error: bad toml",
),
(
RoboticusError::Channel("serialize failed".into()),
"channel error: serialize failed",
),
(
RoboticusError::Database("locked".into()),
"database error: locked",
),
(RoboticusError::Llm("timeout".into()), "LLM error: timeout"),
(
RoboticusError::Network("refused".into()),
"network error: refused",
),
(
RoboticusError::Policy {
rule: "financial".into(),
reason: "over limit".into(),
},
"policy violation: financial -- over limit",
),
(
RoboticusError::Tool {
tool: "git".into(),
message: "not found".into(),
},
"tool error: git -- not found",
),
(
RoboticusError::Wallet("no key".into()),
"wallet error: no key",
),
(
RoboticusError::Injection("override attempt".into()),
"injection detected: override attempt",
),
(
RoboticusError::Schedule("missed".into()),
"schedule error: missed",
),
(
RoboticusError::A2a("handshake failed".into()),
"A2A error: handshake failed",
),
(
RoboticusError::Skill("parse error".into()),
"skill error: parse error",
),
(
RoboticusError::Keystore("locked".into()),
"keystore error: locked",
),
];
for (err, expected) in cases {
assert_eq!(err.to_string(), expected);
}
}
#[test]
fn io_error_conversion() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
let err: RoboticusError = io_err.into();
assert!(matches!(err, RoboticusError::Io(_)));
assert!(err.to_string().contains("missing"));
}
#[test]
fn toml_error_conversion() {
let bad_toml = "[[invalid";
let result: std::result::Result<toml::Value, _> = toml::from_str(bad_toml);
let err: RoboticusError = result.unwrap_err().into();
assert!(matches!(err, RoboticusError::Config(_)));
}
#[test]
fn json_error_conversion() {
let bad_json = "{invalid}";
let result: std::result::Result<serde_json::Value, _> = serde_json::from_str(bad_json);
let err: RoboticusError = result.unwrap_err().into();
assert!(matches!(err, RoboticusError::Config(_)));
}
#[test]
fn result_type_alias() {
let ok: Result<i32> = Ok(42);
assert!(matches!(ok, Ok(42)));
let err: Result<i32> = Err(RoboticusError::Config("test".into()));
assert!(err.is_err());
}
#[test]
fn is_keystore_locked_detects_locked_state() {
assert!(RoboticusError::Keystore("keystore is locked".into()).is_keystore_locked());
assert!(!RoboticusError::Keystore("decryption failed".into()).is_keystore_locked());
assert!(!RoboticusError::Config("locked".into()).is_keystore_locked());
}
#[test]
fn is_credit_error_detects_proxy_circuit_breaker() {
let err = RoboticusError::Llm(
r#"provider returned 429 Too Many Requests: {"error": {"message": "Rate limited — proxy circuit breaker for anthropic (credit)", "type": "rate_limit_error"}}"#.into(),
);
assert!(err.is_credit_error());
}
#[test]
fn is_credit_error_detects_402() {
let err = RoboticusError::Llm(
"provider returned 402 Payment Required: insufficient credits".into(),
);
assert!(err.is_credit_error());
}
#[test]
fn is_credit_error_detects_billing() {
let err = RoboticusError::Llm(
r#"provider returned 403: {"error": {"message": "Your billing account is inactive"}}"#
.into(),
);
assert!(err.is_credit_error());
}
#[test]
fn is_credit_error_detects_quota_exhaustion() {
let err = RoboticusError::Llm(
r#"provider returned 429: {"error": {"message": "You exceeded your current quota"}}"#
.into(),
);
assert!(err.is_credit_error());
}
#[test]
fn is_credit_error_detects_insufficient_quota() {
let err = RoboticusError::Llm(
r#"provider returned 429: {"error": {"type": "insufficient_quota"}}"#.into(),
);
assert!(err.is_credit_error());
}
#[test]
fn is_credit_error_false_for_transient_rate_limit() {
let err = RoboticusError::Llm(
"provider returned 429 Too Many Requests: rate limited, try again".into(),
);
assert!(!err.is_credit_error());
}
#[test]
fn is_credit_error_false_for_non_llm_variants() {
let err = RoboticusError::Config("credit billing".into());
assert!(!err.is_credit_error());
}
#[test]
fn is_credit_error_works_on_network_variant() {
let err =
RoboticusError::Network("provider returned 402 Payment Required: no credits".into());
assert!(err.is_credit_error());
}
#[test]
fn is_credit_error_false_for_other_variants() {
assert!(!RoboticusError::Database("credit billing".into()).is_credit_error());
assert!(!RoboticusError::Channel("credit rate_limit".into()).is_credit_error());
assert!(!RoboticusError::Wallet("billing issue".into()).is_credit_error());
assert!(!RoboticusError::Injection("402 Payment Required".into()).is_credit_error());
assert!(!RoboticusError::Schedule("billing".into()).is_credit_error());
assert!(!RoboticusError::A2a("billing".into()).is_credit_error());
assert!(!RoboticusError::Skill("billing".into()).is_credit_error());
assert!(!RoboticusError::Keystore("billing".into()).is_credit_error());
assert!(
!RoboticusError::Policy {
rule: "credit".into(),
reason: "billing".into()
}
.is_credit_error()
);
assert!(
!RoboticusError::Tool {
tool: "credit".into(),
message: "billing".into()
}
.is_credit_error()
);
let io_err = std::io::Error::other("billing");
assert!(!RoboticusError::Io(io_err).is_credit_error());
}
#[test]
fn is_credit_error_network_billing() {
let err = RoboticusError::Network("Your billing account is inactive".into());
assert!(err.is_credit_error());
}
#[test]
fn is_credit_error_credit_rate_limit_combo() {
let err = RoboticusError::Llm("credit exhausted, rate_limit triggered".into());
assert!(err.is_credit_error());
}
#[test]
fn is_credit_error_credit_circuit_breaker_combo() {
let err = RoboticusError::Llm("credit tripped circuit breaker".into());
assert!(err.is_credit_error());
}
#[test]
fn toml_ser_error_conversion() {
fn force_toml_ser_error<S: serde::Serializer>(
_v: &str,
_s: S,
) -> std::result::Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom("forced error"))
}
#[derive(serde::Serialize)]
struct Bad {
#[serde(serialize_with = "force_toml_ser_error")]
field: String,
}
let result = toml::to_string(&Bad { field: "x".into() });
let err: RoboticusError = result.unwrap_err().into();
assert!(matches!(err, RoboticusError::Config(_)));
assert!(err.to_string().contains("TOML serialization error"));
}
}