Skip to main content

clawft_types/
error.rs

1//! Error types for the clawft framework.
2//!
3//! Provides [`ClawftError`] as the top-level error type and [`ChannelError`]
4//! for channel-specific failures. Both are non-exhaustive to allow future
5//! extension without breaking downstream.
6
7use thiserror::Error;
8
9/// Top-level error type for the clawft framework.
10///
11/// Variants are grouped into recoverable (retry, timeout, rate-limit) and
12/// fatal (config, plugin, I/O) categories to guide callers on whether
13/// retrying is worthwhile.
14#[derive(Error, Debug)]
15#[non_exhaustive]
16pub enum ClawftError {
17    // ── Recoverable ──────────────────────────────────────────────────
18    /// A transient failure that may succeed on retry.
19    #[error("retry required: {source} (attempt {attempts})")]
20    Retry {
21        /// The underlying error.
22        #[source]
23        source: Box<dyn std::error::Error + Send + Sync>,
24        /// How many attempts have been made so far.
25        attempts: u32,
26    },
27
28    /// An operation exceeded its deadline.
29    #[error("operation timed out: {operation}")]
30    Timeout {
31        /// Human-readable name of the operation that timed out.
32        operation: String,
33    },
34
35    /// A provider returned an error (e.g. bad request, server error).
36    #[error("provider error: {message}")]
37    Provider {
38        /// Provider-supplied error message.
39        message: String,
40    },
41
42    /// The provider is throttling requests.
43    #[error("rate limited: retry after {retry_after_ms}ms")]
44    RateLimited {
45        /// Suggested wait time in milliseconds before retrying.
46        retry_after_ms: u64,
47    },
48
49    // ── Fatal ────────────────────────────────────────────────────────
50    /// Configuration is malformed or semantically invalid.
51    #[error("invalid config: {reason}")]
52    ConfigInvalid {
53        /// What is wrong with the configuration.
54        reason: String,
55    },
56
57    /// A plugin/extension could not be loaded.
58    #[error("failed to load plugin: {plugin}")]
59    PluginLoadFailed {
60        /// Name or path of the plugin that failed.
61        plugin: String,
62    },
63
64    /// Underlying I/O error.
65    #[error("io error: {0}")]
66    Io(#[from] std::io::Error),
67
68    /// JSON serialization / deserialization error.
69    #[error("json error: {0}")]
70    Json(#[from] serde_json::Error),
71
72    /// A channel-layer error bubbled up.
73    #[error("channel error: {0}")]
74    Channel(String),
75
76    /// A security boundary was violated (path traversal, oversized input, etc.)
77    #[error("security violation: {reason}")]
78    SecurityViolation {
79        /// What policy was violated.
80        reason: String,
81    },
82}
83
84/// Channel-specific error type.
85///
86/// Used by channel implementations (Telegram, Slack, Discord, etc.)
87/// to report failures in connecting, authenticating, or exchanging messages.
88#[derive(Error, Debug)]
89#[non_exhaustive]
90pub enum ChannelError {
91    /// Failed to establish a connection to the channel backend.
92    #[error("connection failed: {0}")]
93    ConnectionFailed(String),
94
95    /// Authentication / authorization was rejected.
96    #[error("authentication failed: {0}")]
97    AuthFailed(String),
98
99    /// Sending a message failed.
100    #[error("send failed: {0}")]
101    SendFailed(String),
102
103    /// Receiving a message failed.
104    #[error("receive failed: {0}")]
105    ReceiveFailed(String),
106
107    /// The channel is not currently connected.
108    #[error("not connected")]
109    NotConnected,
110
111    /// The requested channel was not found.
112    #[error("channel not found: {0}")]
113    NotFound(String),
114
115    /// Catch-all for errors that do not fit other variants.
116    #[error("{0}")]
117    Other(String),
118}
119
120/// A convenience alias used throughout the crate.
121pub type Result<T> = std::result::Result<T, ClawftError>;
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn clawft_error_display() {
129        let err = ClawftError::Timeout {
130            operation: "llm_call".into(),
131        };
132        assert_eq!(err.to_string(), "operation timed out: llm_call");
133    }
134
135    #[test]
136    fn clawft_error_from_io() {
137        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
138        let err: ClawftError = io_err.into();
139        assert!(matches!(err, ClawftError::Io(_)));
140        assert!(err.to_string().contains("missing"));
141    }
142
143    #[test]
144    fn clawft_error_from_json() {
145        let json_err = serde_json::from_str::<serde_json::Value>("{{bad}}").unwrap_err();
146        let err: ClawftError = json_err.into();
147        assert!(matches!(err, ClawftError::Json(_)));
148    }
149
150    #[test]
151    fn channel_error_display() {
152        let err = ChannelError::NotConnected;
153        assert_eq!(err.to_string(), "not connected");
154
155        let err = ChannelError::AuthFailed("bad token".into());
156        assert_eq!(err.to_string(), "authentication failed: bad token");
157    }
158
159    #[test]
160    fn retry_error_preserves_source() {
161        let source: Box<dyn std::error::Error + Send + Sync> = "transient".into();
162        let err = ClawftError::Retry {
163            source,
164            attempts: 3,
165        };
166        assert!(err.to_string().contains("attempt 3"));
167        assert!(err.to_string().contains("transient"));
168    }
169
170    #[test]
171    fn security_violation_display() {
172        let err = ClawftError::SecurityViolation {
173            reason: "path traversal detected".into(),
174        };
175        assert_eq!(
176            err.to_string(),
177            "security violation: path traversal detected"
178        );
179    }
180
181    #[test]
182    fn result_alias_works() {
183        fn ok_fn() -> Result<i32> {
184            Ok(42)
185        }
186        fn err_fn() -> Result<i32> {
187            Err(ClawftError::Provider {
188                message: "boom".into(),
189            })
190        }
191        assert_eq!(ok_fn().unwrap(), 42);
192        assert!(err_fn().is_err());
193    }
194}