Skip to main content

a3s_code_core/
error.rs

1//! Typed error enum for A3S Code Core
2//!
3//! Provides categorized errors that SDK consumers can match on programmatically,
4//! instead of receiving opaque `anyhow::Error` strings.
5//!
6//! ## Migration Strategy
7//!
8//! The `Internal` variant wraps `anyhow::Error` via `#[from]`, allowing
9//! gradual migration: call sites that haven't been updated yet auto-convert
10//! through `?`. Over time, each call site replaces `anyhow::anyhow!(...)`
11//! with a specific variant like `CodeError::Config(...)`.
12
13use thiserror::Error;
14
15/// Crate-wide result type alias.
16pub type Result<T> = std::result::Result<T, CodeError>;
17
18/// Categorized error type for A3S Code Core.
19///
20/// SDK bindings (Python/Node) can match on the variant to expose typed
21/// exceptions (e.g., `CodeConfigError`, `CodeLlmError`).
22#[derive(Debug, Error)]
23pub enum CodeError {
24    /// Configuration loading or parsing error
25    #[error("Config error: {0}")]
26    Config(String),
27
28    /// LLM provider communication error
29    #[error("LLM error: {0}")]
30    Llm(String),
31
32    /// Tool execution error
33    #[error("Tool error: {tool}: {message}")]
34    Tool { tool: String, message: String },
35
36    /// Session management error
37    #[error("Session error: {0}")]
38    Session(String),
39
40    /// Session has been closed; further operations are rejected.
41    ///
42    /// Returned by `send`/`stream` (and their variants) after
43    /// [`AgentSession::close`](crate::agent_api::AgentSession::close)
44    /// — or [`Agent::close`](crate::agent_api::Agent::close) — has been called.
45    #[error("Session '{session_id}' is closed")]
46    SessionClosed { session_id: String },
47
48    /// A host-supplied [`BudgetGuard`](crate::budget::BudgetGuard) denied
49    /// the operation. The session is not closed — callers can re-try
50    /// after the host has re-allocated budget.
51    #[error("Budget exhausted on '{resource}': {reason}")]
52    BudgetExhausted { resource: String, reason: String },
53
54    /// Security subsystem error
55    #[error("Security error: {0}")]
56    Security(String),
57
58    /// Context provider or context store error
59    #[error("Context error: {0}")]
60    Context(String),
61
62    /// MCP (Model Context Protocol) error
63    #[error("MCP error: {0}")]
64    Mcp(String),
65
66    /// Queue or lane error
67    #[error("Queue error: {0}")]
68    Queue(String),
69
70    /// I/O error
71    #[error("IO error: {0}")]
72    Io(#[from] std::io::Error),
73
74    /// JSON serialization/deserialization error
75    #[error("Serialization error: {0}")]
76    Serialization(#[from] serde_json::Error),
77
78    /// Catch-all for errors not yet migrated to a specific variant.
79    ///
80    /// The `#[from] anyhow::Error` conversion enables gradual migration:
81    /// any function returning `anyhow::Result` can be called with `?` from
82    /// a function returning `crate::error::Result` without changes.
83    #[error("{0:#}")]
84    Internal(#[from] anyhow::Error),
85}
86
87// ============================================================================
88// Lock Poisoning Helpers (Phase 3b)
89// ============================================================================
90
91/// Acquire a read guard, recovering from poison if the lock was poisoned.
92///
93/// Non-security code should never panic on a poisoned lock. The data may
94/// be in an inconsistent state, but crashing the entire process is worse
95/// than serving stale data in a coding agent context.
96pub(crate) fn read_or_recover<T>(lock: &std::sync::RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
97    lock.read().unwrap_or_else(|p| p.into_inner())
98}
99
100/// Acquire a write guard, recovering from poison if the lock was poisoned.
101///
102/// See [`read_or_recover`] for rationale.
103pub(crate) fn write_or_recover<T>(
104    lock: &std::sync::RwLock<T>,
105) -> std::sync::RwLockWriteGuard<'_, T> {
106    lock.write().unwrap_or_else(|p| p.into_inner())
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn test_code_error_config() {
115        let err = CodeError::Config("missing API key".to_string());
116        assert!(err.to_string().contains("Config error"));
117        assert!(err.to_string().contains("missing API key"));
118    }
119
120    #[test]
121    fn test_code_error_llm() {
122        let err = CodeError::Llm("rate limited".to_string());
123        assert!(err.to_string().contains("LLM error"));
124    }
125
126    #[test]
127    fn test_code_error_tool() {
128        let err = CodeError::Tool {
129            tool: "bash".to_string(),
130            message: "command not found".to_string(),
131        };
132        let msg = err.to_string();
133        assert!(msg.contains("bash"));
134        assert!(msg.contains("command not found"));
135    }
136
137    #[test]
138    fn test_code_error_session() {
139        let err = CodeError::Session("not found".to_string());
140        assert!(err.to_string().contains("Session error"));
141    }
142
143    #[test]
144    fn test_code_error_security() {
145        let err = CodeError::Security("taint detected".to_string());
146        assert!(err.to_string().contains("Security error"));
147    }
148
149    #[test]
150    fn test_code_error_context() {
151        let err = CodeError::Context("provider failed".to_string());
152        assert!(err.to_string().contains("Context error"));
153    }
154
155    #[test]
156    fn test_code_error_mcp() {
157        let err = CodeError::Mcp("connection refused".to_string());
158        assert!(err.to_string().contains("MCP error"));
159    }
160
161    #[test]
162    fn test_code_error_queue() {
163        let err = CodeError::Queue("lane full".to_string());
164        assert!(err.to_string().contains("Queue error"));
165    }
166
167    #[test]
168    fn test_code_error_from_io() {
169        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
170        let err: CodeError = io_err.into();
171        assert!(matches!(err, CodeError::Io(_)));
172        assert!(err.to_string().contains("file missing"));
173    }
174
175    #[test]
176    fn test_code_error_from_serde_json() {
177        let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
178        let err: CodeError = json_err.into();
179        assert!(matches!(err, CodeError::Serialization(_)));
180    }
181
182    #[test]
183    fn test_code_error_from_anyhow() {
184        let anyhow_err = anyhow::anyhow!("something went wrong");
185        let err: CodeError = anyhow_err.into();
186        assert!(matches!(err, CodeError::Internal(_)));
187        assert!(err.to_string().contains("something went wrong"));
188    }
189
190    #[test]
191    fn test_code_error_question_mark_from_anyhow() {
192        fn inner() -> anyhow::Result<()> {
193            anyhow::bail!("inner error")
194        }
195
196        fn outer() -> Result<()> {
197            inner()?; // anyhow::Error -> CodeError::Internal via #[from]
198            Ok(())
199        }
200
201        let result = outer();
202        assert!(result.is_err());
203        let err = result.unwrap_err();
204        assert!(matches!(err, CodeError::Internal(_)));
205    }
206
207    #[test]
208    fn test_read_or_recover_normal() {
209        let lock = std::sync::RwLock::new(42);
210        let guard = read_or_recover(&lock);
211        assert_eq!(*guard, 42);
212    }
213
214    #[test]
215    fn test_write_or_recover_normal() {
216        let lock = std::sync::RwLock::new(42);
217        let mut guard = write_or_recover(&lock);
218        *guard = 99;
219        drop(guard);
220        assert_eq!(*read_or_recover(&lock), 99);
221    }
222
223    #[test]
224    fn test_read_or_recover_poisoned() {
225        let lock = std::sync::RwLock::new(42);
226        // Poison the lock by panicking while holding a write guard
227        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
228            let _guard = lock.write().unwrap();
229            panic!("intentional poison");
230        }));
231        // Should recover without panicking
232        let guard = read_or_recover(&lock);
233        assert_eq!(*guard, 42);
234    }
235
236    #[test]
237    fn test_write_or_recover_poisoned() {
238        let lock = std::sync::RwLock::new(42);
239        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
240            let _guard = lock.write().unwrap();
241            panic!("intentional poison");
242        }));
243        let mut guard = write_or_recover(&lock);
244        *guard = 100;
245        assert_eq!(*guard, 100);
246    }
247}