Skip to main content

oxicode_sdk/
error.rs

1//! oxicode-sdk error types
2//!
3//! Structured error enum for SDK consumers to match against.
4
5use thiserror::Error;
6
7/// Unified SDK result type.
8pub type SdkResult<T> = Result<T, SdkError>;
9
10/// oxicode-sdk structured error type.
11///
12/// SDK consumers can use `match` to handle specific error cases.
13/// Internal implementations may still use `anyhow`, converted at public API boundaries.
14/// `#[non_exhaustive]` — consumers MUST add a catch-all `_ =>` arm in their
15/// `match` expressions so that new variants added in future minor releases do
16/// not break compilation. Existing named variants are frozen; their meaning
17/// does not change between releases (see `docs/release-process.md`).
18#[derive(Debug, Error)]
19#[non_exhaustive]
20pub enum SdkError {
21    // ── Model/Provider ────────────────────────────────────────────────────────
22    /// The requested model could not be resolved by any configured provider.
23    #[error("model not found: {model_id}")]
24    ModelNotFound {
25        /// Identifier of the model that was requested.
26        model_id: String,
27    },
28
29    /// No provider matching the requested name is registered with the SDK.
30    #[error("provider not found: {provider}")]
31    ProviderNotFound {
32        /// Name of the provider that was requested.
33        provider: String,
34    },
35
36    /// Every provider in the failover chain was attempted and failed.
37    #[error("all providers exhausted: {attempts} attempts")]
38    AllProvidersExhausted {
39        /// Number of provider attempts made before giving up.
40        attempts: usize,
41    },
42
43    /// The model exists but is excluded from resolution by routing control.
44    #[error("model '{model_id}' excluded by routing control")]
45    ModelExcluded {
46        /// Identifier of the excluded model.
47        model_id: String,
48    },
49
50    // ── Agent Lifecycle ────────────────────────────────────────────────────────
51    /// The agent exists but cannot run in its current lifecycle status.
52    #[error("agent {agent_id} not runnable (status: {status})")]
53    AgentNotRunnable {
54        /// Identifier of the agent.
55        agent_id: String,
56        /// Current lifecycle status of the agent.
57        status: String,
58    },
59
60    /// An attempt was made to start an agent that is already running.
61    #[error("agent {agent_id} already running")]
62    AgentAlreadyRunning {
63        /// Identifier of the agent.
64        agent_id: String,
65    },
66
67    /// No persisted snapshot could be found for the agent.
68    #[error("snapshot not found: {agent_id}")]
69    SnapshotNotFound {
70        /// Identifier of the agent whose snapshot was requested.
71        agent_id: String,
72    },
73
74    /// A persisted snapshot exists but failed integrity validation.
75    #[error("snapshot corrupt: {agent_id}: {reason}")]
76    SnapshotCorrupt {
77        /// Identifier of the agent whose snapshot is corrupt.
78        agent_id: String,
79        /// Human-readable explanation of the corruption.
80        reason: String,
81    },
82
83    // ── Security ─────────────────────────────────────────────────────────────
84    /// The principal lacks a required capability for the requested action.
85    #[error("permission denied: {subject} requires {capability}")]
86    PermissionDenied {
87        /// The principal (agent, user, or component) requesting access.
88        subject: String,
89        /// The capability that would be required to permit the action.
90        capability: String,
91    },
92
93    /// A previously granted capability has expired and is no longer valid.
94    #[error("capability expired: {subject}")]
95    CapabilityExpired {
96        /// The principal whose capability expired.
97        subject: String,
98    },
99
100    // ── Coordination ─────────────────────────────────────────────────────────
101    /// A referenced coordination work item does not exist.
102    #[error("work item not found: {item_id}")]
103    WorkItemNotFound {
104        /// Identifier of the missing work item.
105        item_id: String,
106    },
107
108    /// An optimistic-concurrency guard detected a stale version of a value.
109    #[error("version conflict on {key}: expected {expected}, current {current}")]
110    VersionConflict {
111        /// The key whose version was in conflict.
112        key: String,
113        /// Version the caller expected to observe.
114        expected: u64,
115        /// Version currently stored.
116        current: u64,
117    },
118
119    /// A referenced vote session could not be found.
120    #[error("vote session not found: {vote_id}")]
121    VoteNotFound {
122        /// Identifier of the missing vote session.
123        vote_id: String,
124    },
125
126    /// A state-machine precondition was violated (wrong status for the operation).
127    #[error("invalid state for {entity}: {reason}")]
128    InvalidState {
129        /// The entity whose state was invalid.
130        entity: String,
131        /// Why the transition was rejected.
132        reason: String,
133    },
134
135    // ── Middleware ───────────────────────────────────────────────────────────
136    /// A middleware intercepted and blocked the request.
137    #[error("middleware blocked: {middleware}: {reason}")]
138    MiddlewareBlocked {
139        /// Name of the middleware that blocked the request.
140        middleware: String,
141        /// Why the middleware blocked the request.
142        reason: String,
143    },
144
145    /// The configured token-usage budget for a run was exceeded.
146    #[error("token budget exceeded: {used} / {budget}")]
147    TokenBudgetExceeded {
148        /// Number of tokens consumed so far.
149        used: usize,
150        /// Maximum number of tokens permitted by the budget.
151        budget: usize,
152    },
153
154    /// The configured monetary cost budget for a run was exceeded.
155    #[error("cost budget exceeded: ${used:.4} / ${budget:.4}")]
156    CostBudgetExceeded {
157        /// Cost consumed so far, in currency units.
158        used: f64,
159        /// Maximum cost permitted by the budget.
160        budget: f64,
161    },
162
163    // ── Routing ─────────────────────────────────────────────────────────────
164    /// Routing is disabled in the current configuration.
165    #[error("routing disabled")]
166    RoutingDisabled,
167
168    /// No route could be determined for the requested model.
169    #[error("no route available for model: {model_id}")]
170    NoRouteAvailable {
171        /// Identifier of the model for which no route exists.
172        model_id: String,
173    },
174
175    // ── Agent Execution ─────────────────────────────────────────────────
176    /// A single agent's execution failed.
177    #[error("agent execution failed: {reason}")]
178    ExecutionFailed {
179        /// Human-readable reason for the failure.
180        reason: String,
181    },
182
183    /// One or more agents in a group run failed.
184    #[error("agent group failed: {failed}/{total} agents")]
185    GroupExecutionFailed {
186        /// Number of agents in the group that failed.
187        failed: usize,
188        /// Total number of agents in the group.
189        total: usize,
190    },
191
192    /// The run was cancelled before completing.
193    #[error("run cancelled")]
194    Cancelled,
195
196    // ── Ports ───────────────────────────────────────────────────────────────
197    /// A required port was never configured on the builder.
198    #[error("port not configured: {port} (use OxicodeBuilder::with_port_*(...))")]
199    PortNotConfigured {
200        /// Name of the missing port.
201        port: &'static str,
202    },
203
204    /// A URL scheme has no registered handler.
205    #[error("unknown URL scheme: {scheme} (no handler registered)")]
206    UnknownScheme {
207        /// The unrecognized URL scheme.
208        scheme: String,
209    },
210
211    /// A file-system or device I/O error in a port adapter.
212    #[error("I/O error: {0}")]
213    Io(#[from] std::io::Error),
214
215    /// A serialization or deserialization failure.
216    #[error("{context}: {source}")]
217    Serialization {
218        /// "encode" or "decode".
219        context: &'static str,
220        /// The underlying serde error.
221        #[source]
222        source: serde_json::Error,
223    },
224
225    /// An entry with the same key or path already exists.
226    #[error("already exists: {key}")]
227    AlreadyExists {
228        /// The duplicate key or path.
229        key: String,
230    },
231
232    // ── Catalog ─────────────────────────────────────────────────────────────
233    /// The catalog port is not wired or returned no data for the request.
234    #[error("catalog unavailable: {reason}")]
235    CatalogUnavailable {
236        /// Why the catalog was unavailable.
237        reason: String,
238    },
239
240    /// A user-supplied catalog override file failed to parse.
241    #[error("catalog override parse error at {path}: {reason}")]
242    CatalogOverrideParse {
243        /// Path to the override file that failed to parse.
244        path: String,
245        /// Why the override file could not be parsed.
246        reason: String,
247    },
248
249    /// A catalog refresh attempt failed (network, HTTP, or parse error).
250    /// The stale snapshot is still served; this error is informational.
251    #[error("catalog refresh failed: {reason}")]
252    CatalogRefresh {
253        /// Why the refresh attempt failed.
254        reason: String,
255    },
256
257    // ── General ─────────────────────────────────────────────────────────────
258    /// An unexpected internal error, converted from an [`anyhow::Error`].
259    #[error("{0}")]
260    Internal(#[from] anyhow::Error),
261}
262
263impl SdkError {
264    /// Returns true if this is an internal/unexpected error.
265    pub fn is_internal(&self) -> bool {
266        matches!(self, SdkError::Internal(_))
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_sdk_error_display() {
276        let err = SdkError::ModelNotFound {
277            model_id: "test-model".into(),
278        };
279        assert!(err.to_string().contains("test-model"));
280
281        let err = SdkError::PermissionDenied {
282            subject: "agent-001".into(),
283            capability: "FileWrite".into(),
284        };
285        assert!(err.to_string().contains("agent-001"));
286        assert!(err.to_string().contains("FileWrite"));
287    }
288
289    #[test]
290    fn test_sdk_error_from_anyhow() {
291        let anyhow_err = anyhow::anyhow!("test error");
292        let sdk_err: SdkError = SdkError::from(anyhow_err);
293        assert!(sdk_err.is_internal());
294    }
295
296    #[test]
297    fn test_version_conflict_error() {
298        let err = SdkError::VersionConflict {
299            key: "counter".into(),
300            expected: 5,
301            current: 7,
302        };
303        let msg = err.to_string();
304        assert!(msg.contains("counter"));
305        assert!(msg.contains("5"));
306        assert!(msg.contains("7"));
307    }
308
309    #[test]
310    fn test_execution_failed_display() {
311        let err = SdkError::ExecutionFailed {
312            reason: "provider timeout".into(),
313        };
314        let msg = err.to_string();
315        assert!(msg.contains("agent execution failed"));
316        assert!(msg.contains("provider timeout"));
317    }
318
319    #[test]
320    fn test_group_execution_failed_display() {
321        let err = SdkError::GroupExecutionFailed {
322            failed: 2,
323            total: 5,
324        };
325        let msg = err.to_string();
326        assert!(msg.contains("agent group failed"));
327        assert!(msg.contains("2/5"));
328    }
329
330    #[test]
331    fn test_cancelled_display() {
332        let err = SdkError::Cancelled;
333        assert_eq!(err.to_string(), "run cancelled");
334    }
335
336    #[test]
337    fn test_sdk_result_ok() {
338        let result: SdkResult<i32> = Ok(42);
339        assert!(matches!(result, Ok(42)));
340    }
341
342    #[test]
343    fn test_sdk_result_err() {
344        let result: SdkResult<i32> = Err(SdkError::Cancelled);
345        assert!(matches!(result, Err(SdkError::Cancelled)));
346    }
347}