Skip to main content

ops_rs/
error.rs

1use crate::prelude::*;
2use thiserror::Error;
3
4#[derive(Error, Debug)]
5pub enum OpError {
6    #[error("Op execution failed: {0}")]
7    ExecutionFailed(String),
8
9    #[error("Op timeout after {timeout_ms}ms")]
10    Timeout { timeout_ms: u64 },
11
12    #[error("Context error: {0}")]
13    Context(String),
14
15    #[error("Batch op failed: {0}")]
16    BatchFailed(String),
17
18    /// A CLASSIFIED failure inside wrapping context (a batch child, a
19    /// trigger-wrapped op, …) — the wrapper preserves the origin's failure
20    /// identity instead of flattening it into prose. `chain` is the wrapping
21    /// text (which op, which index) for humans; class/code/reason are the
22    /// origin's, verbatim.
23    #[error("{chain}")]
24    WrappedClassified {
25        chain: String,
26        code: String,
27        class: crate::failure::AttributionClass,
28        reason: String,
29        /// Media URN of the ARGUMENT the origin attributed the failure to,
30        /// when — and only when — its emit source could name one (argument
31        /// binding/validation failures can; runtime ERR frames cannot).
32        /// Carried verbatim through wrapping, never invented downstream.
33        arg_urn: Option<String>,
34    },
35
36    #[error("Op aborted: {0}")]
37    Aborted(String),
38
39    #[error("Trigger error: {0}")]
40    Trigger(String),
41
42    /// A failure carrying its FULL identity from the emit source: the
43    /// machine-readable `code` the origin error declares (`error_code()`),
44    /// the failure CLASS it declares (`attribution_class()` — whose problem it
45    /// is), and the leaf human message. Wrapping layers construct this from
46    /// classified origins instead of folding everything into prose; the
47    /// engine's run record and retry policy read it structurally.
48    #[error("{code}: {message}")]
49    Classified {
50        code: String,
51        class: crate::failure::AttributionClass,
52        message: String,
53        /// Media URN of the ARGUMENT the emit source attributed the failure
54        /// to, when it could name one. `None` means "no single argument is
55        /// attributable" and is never upgraded by wrapping layers
56        /// (docs/failure-taxonomy.md discipline).
57        arg_urn: Option<String>,
58    },
59
60    #[error(transparent)]
61    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
62}
63
64impl OpError {
65    /// The failure class this error DECLARES. Classified variants carry
66    /// their origin's declaration; everything else is `Internal` —
67    /// unclassified means "ours", never a guess (docs/failure-taxonomy.md).
68    pub fn attribution_class(&self) -> crate::failure::AttributionClass {
69        match self {
70            Self::Classified { class, .. } => *class,
71            Self::WrappedClassified { class, .. } => *class,
72            _ => crate::failure::AttributionClass::Internal,
73        }
74    }
75
76    /// The machine-readable code declared at the emit source, when the
77    /// failure carried one.
78    pub fn failure_code(&self) -> Option<&str> {
79        match self {
80            Self::Classified { code, .. } => Some(code),
81            Self::WrappedClassified { code, .. } => Some(code),
82            _ => None,
83        }
84    }
85
86    /// Media URN of the argument the failure is attributed to, when the
87    /// emit source declared one. `None` for every unclassified variant and
88    /// for classified failures whose source could not name a single
89    /// offending argument — never guessed here.
90    pub fn failure_arg_urn(&self) -> Option<&str> {
91        match self {
92            Self::Classified { arg_urn, .. } => arg_urn.as_deref(),
93            Self::WrappedClassified { arg_urn, .. } => arg_urn.as_deref(),
94            _ => None,
95        }
96    }
97
98    /// The LEAF human reason — the origin's own message for classified
99    /// failures, the Display chain otherwise.
100    pub fn failure_reason(&self) -> String {
101        match self {
102            Self::Classified { message, .. } => message.clone(),
103            Self::WrappedClassified { reason, .. } => reason.clone(),
104            other => other.to_string(),
105        }
106    }
107}
108
109impl Clone for OpError {
110    fn clone(&self) -> Self {
111        match self {
112            Self::ExecutionFailed(msg) => Self::ExecutionFailed(msg.clone()),
113            Self::Timeout { timeout_ms } => Self::Timeout {
114                timeout_ms: *timeout_ms,
115            },
116            Self::Context(msg) => Self::Context(msg.clone()),
117            Self::BatchFailed(msg) => Self::BatchFailed(msg.clone()),
118            Self::WrappedClassified {
119                chain,
120                code,
121                class,
122                reason,
123                arg_urn,
124            } => Self::WrappedClassified {
125                chain: chain.clone(),
126                code: code.clone(),
127                class: *class,
128                reason: reason.clone(),
129                arg_urn: arg_urn.clone(),
130            },
131            Self::Aborted(msg) => Self::Aborted(msg.clone()),
132            Self::Trigger(msg) => Self::Trigger(msg.clone()),
133            Self::Classified {
134                code,
135                class,
136                message,
137                arg_urn,
138            } => Self::Classified {
139                code: code.clone(),
140                class: *class,
141                message: message.clone(),
142                arg_urn: arg_urn.clone(),
143            },
144            Self::Other(boxed_error) => Self::ExecutionFailed(format!("{}", boxed_error)),
145        }
146    }
147}
148
149impl From<serde_json::Error> for OpError {
150    fn from(e: serde_json::Error) -> Self {
151        OpError::Other(Box::new(e))
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    // TEST0104: Verify OpError::ExecutionFailed displays with the correct message format
160    #[test]
161    fn test0104_op_error_display_execution_failed() {
162        let err = OpError::ExecutionFailed("something broke".to_string());
163        assert_eq!(err.to_string(), "Op execution failed: something broke");
164    }
165
166    // TEST0105: Verify OpError::Timeout displays with the correct timeout_ms value
167    #[test]
168    fn test0105_op_error_display_timeout() {
169        let err = OpError::Timeout { timeout_ms: 250 };
170        assert_eq!(err.to_string(), "Op timeout after 250ms");
171    }
172
173    // TEST0106: Verify OpError::Context displays with the correct message format
174    #[test]
175    fn test0106_op_error_display_context() {
176        let err = OpError::Context("missing key".to_string());
177        assert_eq!(err.to_string(), "Context error: missing key");
178    }
179
180    // TEST0107: Verify OpError::Aborted displays with the correct message format
181    #[test]
182    fn test0107_op_error_display_aborted() {
183        let err = OpError::Aborted("user cancelled".to_string());
184        assert_eq!(err.to_string(), "Op aborted: user cancelled");
185    }
186
187    // TEST0108: Clone an OpError::ExecutionFailed and verify the clone is identical
188    #[test]
189    fn test0108_op_error_clone_execution_failed() {
190        let err = OpError::ExecutionFailed("fail msg".to_string());
191        let cloned = err.clone();
192        assert_eq!(err.to_string(), cloned.to_string());
193        match cloned {
194            OpError::ExecutionFailed(msg) => assert_eq!(msg, "fail msg"),
195            _ => panic!("wrong variant"),
196        }
197    }
198
199    // TEST0109: Clone OpError::Timeout and verify timeout_ms is preserved
200    #[test]
201    fn test0109_op_error_clone_timeout() {
202        let err = OpError::Timeout { timeout_ms: 500 };
203        let cloned = err.clone();
204        match cloned {
205            OpError::Timeout { timeout_ms } => assert_eq!(timeout_ms, 500),
206            _ => panic!("wrong variant"),
207        }
208    }
209
210    // TEST0110: Clone OpError::Other and verify it becomes ExecutionFailed with the error message preserved
211    #[test]
212    fn test0110_op_error_clone_other_converts_to_execution_failed() {
213        use std::io;
214        let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
215        let err = OpError::Other(Box::new(io_err));
216        let cloned = err.clone();
217        // Other cannot be cloned directly — it converts to ExecutionFailed preserving the message
218        match cloned {
219            OpError::ExecutionFailed(msg) => assert!(msg.contains("file missing")),
220            _ => panic!("expected ExecutionFailed from cloned Other"),
221        }
222    }
223
224    // TEST1901: classified variants carry the emit source's identity through
225    // the accessors; unclassified variants are Internal with no code — the
226    // taxonomy's own rule (docs/failure-taxonomy.md).
227    #[test]
228    fn test1901_classified_accessors() {
229        use crate::failure::AttributionClass;
230
231        let classified = OpError::Classified {
232            code: "CONTEXT_OVERFLOW".to_string(),
233            class: AttributionClass::Input,
234            message: "prompt too large".to_string(),
235            arg_urn: Some("media:prompt;textable".to_string()),
236        };
237        assert_eq!(classified.attribution_class(), AttributionClass::Input);
238        assert_eq!(classified.failure_code(), Some("CONTEXT_OVERFLOW"));
239        assert_eq!(classified.failure_reason(), "prompt too large");
240        assert_eq!(
241            classified.failure_arg_urn(),
242            Some("media:prompt;textable"),
243            "the emit source's argument attribution is served structurally"
244        );
245        assert_eq!(classified.to_string(), "CONTEXT_OVERFLOW: prompt too large");
246
247        let wrapped = OpError::WrappedClassified {
248            chain: "Op 3-generate failed: CONTEXT_OVERFLOW: prompt too large".to_string(),
249            code: "CONTEXT_OVERFLOW".to_string(),
250            class: AttributionClass::Input,
251            reason: "prompt too large".to_string(),
252            arg_urn: None,
253        };
254        assert_eq!(wrapped.attribution_class(), AttributionClass::Input);
255        assert_eq!(wrapped.failure_code(), Some("CONTEXT_OVERFLOW"));
256        assert_eq!(
257            wrapped.failure_arg_urn(),
258            None,
259            "no attribution declared means none served — never guessed"
260        );
261        assert_eq!(
262            wrapped.failure_reason(),
263            "prompt too large",
264            "the reason is the LEAF message, not the wrap chain"
265        );
266        assert_eq!(
267            wrapped.to_string(),
268            "Op 3-generate failed: CONTEXT_OVERFLOW: prompt too large",
269            "Display keeps the human chain"
270        );
271
272        let plain = OpError::ExecutionFailed("boom".to_string());
273        assert_eq!(plain.attribution_class(), AttributionClass::Internal);
274        assert_eq!(plain.failure_code(), None);
275    }
276
277    // TEST1902: cloning a classified error preserves its full identity —
278    // the run-record path clones the terminal error before persisting.
279    #[test]
280    fn test1902_clone_preserves_classification() {
281        use crate::failure::AttributionClass;
282
283        let original = OpError::WrappedClassified {
284            chain: "Op 'x' failed: GPU_OUT_OF_MEMORY: no VRAM".to_string(),
285            code: "GPU_OUT_OF_MEMORY".to_string(),
286            class: AttributionClass::Resource,
287            reason: "no VRAM".to_string(),
288            arg_urn: Some("media:model-spec;textable".to_string()),
289        };
290        let cloned = original.clone();
291        assert_eq!(cloned.attribution_class(), AttributionClass::Resource);
292        assert_eq!(cloned.failure_code(), Some("GPU_OUT_OF_MEMORY"));
293        assert_eq!(cloned.failure_reason(), "no VRAM");
294        assert_eq!(cloned.failure_arg_urn(), Some("media:model-spec;textable"));
295    }
296
297    // TEST0111: Convert a serde_json::Error into OpError via From impl
298    #[test]
299    fn test0111_op_error_from_serde_json_error() {
300        let json_err = serde_json::from_str::<i32>("not_a_number").unwrap_err();
301        let op_err: OpError = json_err.into();
302        // Must be the Other variant wrapping the serde error
303        match op_err {
304            OpError::Other(_) => {}
305            _ => panic!("expected Other variant from serde_json::Error conversion"),
306        }
307    }
308}