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}