Skip to main content

camel_component_wasm/
error.rs

1//! WASM component error types.
2//!
3//! Provides structured error classification for WASM execution failures:
4//! - Timeout (epoch deadline expired)
5//! - Trap (guest panic, stack overflow, unreachable, etc.)
6//! - Out of memory (linear memory exceeded limit)
7//! - Unhealthy (consecutive failures, unable to re-instantiate)
8//!
9//! All variants carry context (plugin name, timeout value, etc.) for debugging
10//! and error handler integration (dead letter channel, retry policies).
11
12use camel_api::CamelError;
13
14/// Classification of a WASM trap reason.
15///
16/// Used to provide structured error information to error handlers
17/// instead of a flat string message.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TrapReason {
20    /// Execution exceeded the configured timeout.
21    Timeout,
22    /// Linear memory exceeded the configured limit.
23    OutOfMemory,
24    /// `unreachable` instruction executed (guest panic).
25    Unreachable,
26    /// Call stack exceeded limit.
27    StackOverflow,
28    /// Other trap with description.
29    Other(String),
30}
31
32impl std::fmt::Display for TrapReason {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::Timeout => write!(f, "execution timeout"),
36            Self::OutOfMemory => write!(f, "out of memory"),
37            Self::Unreachable => write!(f, "unreachable instruction"),
38            Self::StackOverflow => write!(f, "stack overflow"),
39            Self::Other(msg) => write!(f, "{msg}"),
40        }
41    }
42}
43
44/// Errors that can occur during WASM plugin execution.
45// TODO(WASM-005): WasmError may conflict with WIT-generated types in bindings.
46// If WIT adds an error resource named WasmError, rename this to CamelWasmError.
47#[derive(Debug, thiserror::Error)]
48pub enum WasmError {
49    #[error("WASM module not found: {0}")]
50    ModuleNotFound(String),
51
52    #[error("WASM compilation failed: {0}")]
53    CompilationFailed(String),
54
55    #[error("WASM instantiation failed: {0}")]
56    InstantiationFailed(String),
57
58    #[error("WASM guest panicked (trap): {0}")]
59    GuestPanic(String),
60
61    #[error("WASM type conversion failed: {0}")]
62    TypeConversion(String),
63
64    #[error("WASM I/O error: {0}")]
65    Io(String),
66
67    #[error("WASM configuration error: {0}")]
68    Config(String),
69
70    // ── Phase 4: Structured error variants ──────────────────────────────
71    #[error("WASM plugin '{plugin}' timed out after {timeout_secs}s")]
72    Timeout { plugin: String, timeout_secs: u64 },
73
74    #[error("WASM plugin '{plugin}' trapped: {reason}")]
75    Trap { plugin: String, reason: TrapReason },
76
77    #[error("WASM plugin '{plugin}' exceeded memory limit ({max_memory_bytes} bytes)")]
78    OutOfMemory {
79        plugin: String,
80        max_memory_bytes: u64,
81    },
82
83    #[error("WASM plugin '{plugin}' is unhealthy: {detail}")]
84    Unhealthy { plugin: String, detail: String },
85}
86
87impl WasmError {
88    /// Classify a wasmtime `Trap` into a `TrapReason`.
89    ///
90    /// Uses the trap type for well-known cases and falls back to
91    /// message matching for epoch-related traps.
92    ///
93    /// **Note:** `wasmtime::Trap` is `#[non_exhaustive]` — the `other` catch-all
94    /// arm is **required** and handles any future trap variants added by wasmtime.
95    pub fn classify_trap(trap: &wasmtime::Trap) -> TrapReason {
96        match trap {
97            wasmtime::Trap::StackOverflow => TrapReason::StackOverflow,
98            wasmtime::Trap::MemoryOutOfBounds => TrapReason::OutOfMemory,
99            wasmtime::Trap::UnreachableCodeReached => TrapReason::Unreachable,
100            wasmtime::Trap::Interrupt => TrapReason::Timeout,
101            // #[non_exhaustive] catch-all
102            other => {
103                let msg = other.to_string();
104                if msg.contains("epoch") {
105                    TrapReason::Timeout
106                } else {
107                    TrapReason::Other(msg)
108                }
109            }
110        }
111    }
112
113    /// Returns the plugin name associated with this error, if any.
114    pub fn plugin_name(&self) -> Option<&str> {
115        match self {
116            Self::Timeout { plugin, .. } => Some(plugin),
117            Self::Trap { plugin, .. } => Some(plugin),
118            Self::OutOfMemory { plugin, .. } => Some(plugin),
119            Self::Unhealthy { plugin, .. } => Some(plugin),
120            _ => None,
121        }
122    }
123}
124
125impl From<WasmError> for CamelError {
126    fn from(err: WasmError) -> Self {
127        match &err {
128            WasmError::GuestPanic(msg) => CamelError::ProcessorError(format!("wasm trap: {msg}")),
129            WasmError::TypeConversion(msg) => CamelError::TypeConversionFailed(msg.clone()),
130            WasmError::ModuleNotFound(msg) => CamelError::ComponentNotFound(msg.clone()),
131            WasmError::CompilationFailed(msg) => {
132                CamelError::Config(format!("wasm compilation failed: {msg}"))
133            }
134            WasmError::InstantiationFailed(msg) => {
135                CamelError::Config(format!("wasm instantiation failed: {msg}"))
136            }
137            WasmError::Io(msg) => CamelError::Io(msg.clone()),
138            WasmError::Config(msg) => CamelError::Config(msg.clone()),
139            // ── Phase 4 structured variants ──
140            WasmError::Timeout {
141                plugin,
142                timeout_secs,
143            } => CamelError::ProcessorError(format!(
144                "WASM plugin '{}' timed out after {}s",
145                plugin, timeout_secs
146            )),
147            WasmError::Trap { plugin, reason } => {
148                CamelError::ProcessorError(format!("wasm trap: plugin '{}': {}", plugin, reason))
149            }
150            WasmError::OutOfMemory {
151                plugin,
152                max_memory_bytes,
153            } => CamelError::ProcessorError(format!(
154                "WASM plugin '{}' exceeded memory limit ({} bytes)",
155                plugin, max_memory_bytes
156            )),
157            WasmError::Unhealthy { plugin, detail } => CamelError::ProcessorError(format!(
158                "WASM plugin '{}' is unhealthy: {}",
159                plugin, detail
160            )),
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_trap_reason_display() {
171        assert_eq!(TrapReason::Timeout.to_string(), "execution timeout");
172        assert_eq!(TrapReason::OutOfMemory.to_string(), "out of memory");
173        assert_eq!(
174            TrapReason::Unreachable.to_string(),
175            "unreachable instruction"
176        );
177        assert_eq!(TrapReason::StackOverflow.to_string(), "stack overflow");
178        assert_eq!(
179            TrapReason::Other("custom".to_string()).to_string(),
180            "custom"
181        );
182    }
183
184    #[test]
185    fn test_classify_trap_stack_overflow() {
186        let trap = wasmtime::Trap::StackOverflow;
187        assert!(matches!(
188            WasmError::classify_trap(&trap),
189            TrapReason::StackOverflow
190        ));
191    }
192
193    #[test]
194    fn test_classify_trap_memory_out_of_bounds() {
195        let trap = wasmtime::Trap::MemoryOutOfBounds;
196        assert!(matches!(
197            WasmError::classify_trap(&trap),
198            TrapReason::OutOfMemory
199        ));
200    }
201
202    #[test]
203    fn test_classify_trap_unreachable() {
204        let trap = wasmtime::Trap::UnreachableCodeReached;
205        assert!(matches!(
206            WasmError::classify_trap(&trap),
207            TrapReason::Unreachable
208        ));
209    }
210
211    #[test]
212    fn test_wasm_error_timeout_display() {
213        let err = WasmError::Timeout {
214            plugin: "my_plugin".to_string(),
215            timeout_secs: 30,
216        };
217        let msg = err.to_string();
218        assert!(msg.contains("my_plugin"));
219        assert!(msg.contains("30"));
220        assert!(msg.contains("timed out"));
221    }
222
223    #[test]
224    fn test_wasm_error_trap_display() {
225        let err = WasmError::Trap {
226            plugin: "my_plugin".to_string(),
227            reason: TrapReason::StackOverflow,
228        };
229        let msg = err.to_string();
230        assert!(msg.contains("my_plugin"));
231        assert!(msg.contains("stack overflow"));
232    }
233
234    #[test]
235    fn test_wasm_error_out_of_memory_display() {
236        let err = WasmError::OutOfMemory {
237            plugin: "my_plugin".to_string(),
238            max_memory_bytes: 52428800,
239        };
240        let msg = err.to_string();
241        assert!(msg.contains("my_plugin"));
242        assert!(msg.contains("52428800"));
243    }
244
245    #[test]
246    fn test_wasm_error_unhealthy_display() {
247        let err = WasmError::Unhealthy {
248            plugin: "my_plugin".to_string(),
249            detail: "consecutive failures".to_string(),
250        };
251        let msg = err.to_string();
252        assert!(msg.contains("my_plugin"));
253        assert!(msg.contains("consecutive failures"));
254    }
255
256    #[test]
257    fn test_wasm_error_to_camel_error_timeout() {
258        let err = WasmError::Timeout {
259            plugin: "p".to_string(),
260            timeout_secs: 10,
261        };
262        let camel: CamelError = err.into();
263        let msg = camel.to_string();
264        assert!(msg.contains("timed out"));
265        assert!(msg.contains("10"));
266    }
267
268    #[test]
269    fn test_wasm_error_to_camel_error_trap() {
270        let err = WasmError::Trap {
271            plugin: "p".to_string(),
272            reason: TrapReason::Unreachable,
273        };
274        let camel: CamelError = err.into();
275        let msg = camel.to_string();
276        assert!(msg.contains("unreachable"));
277    }
278
279    #[test]
280    fn test_wasm_error_to_camel_error_out_of_memory() {
281        let err = WasmError::OutOfMemory {
282            plugin: "p".to_string(),
283            max_memory_bytes: 1024,
284        };
285        let camel: CamelError = err.into();
286        let msg = camel.to_string();
287        assert!(msg.contains("memory"));
288    }
289
290    #[test]
291    fn test_wasm_error_to_camel_error_unhealthy() {
292        let err = WasmError::Unhealthy {
293            plugin: "p".to_string(),
294            detail: "broken".to_string(),
295        };
296        let camel: CamelError = err.into();
297        assert!(matches!(camel, CamelError::ProcessorError(_)));
298    }
299
300    #[test]
301    fn test_guest_panic_still_maps_to_processor_error() {
302        let err = WasmError::GuestPanic("boom".to_string());
303        let camel: CamelError = err.into();
304        assert!(matches!(camel, CamelError::ProcessorError(_)));
305        assert!(camel.to_string().contains("wasm trap"));
306    }
307
308    #[test]
309    fn test_instantiation_maps_to_config_error() {
310        let err = WasmError::InstantiationFailed("bad import".to_string());
311        let camel: CamelError = err.into();
312        assert!(matches!(camel, CamelError::Config(_)));
313        assert!(camel.to_string().contains("instantiation failed"));
314    }
315
316    #[test]
317    fn test_plugin_name() {
318        let err = WasmError::Timeout {
319            plugin: "test".to_string(),
320            timeout_secs: 5,
321        };
322        assert_eq!(err.plugin_name(), Some("test"));
323
324        let err = WasmError::GuestPanic("msg".to_string());
325        assert_eq!(err.plugin_name(), None);
326    }
327}