Skip to main content

swf_runtime/
error.rs

1use serde_json::{json, Value};
2use std::fmt;
3
4/// Common fields shared by all workflow error types
5#[derive(Debug, Clone)]
6pub struct ErrorFields {
7    pub message: String,
8    pub task: String,
9    pub instance: String,
10    pub status: Option<Value>,
11    pub title: Option<String>,
12    pub detail: Option<String>,
13    pub original_type: Option<String>,
14}
15
16impl ErrorFields {
17    fn new(
18        message: impl Into<String>,
19        task: impl Into<String>,
20        instance: impl Into<String>,
21    ) -> Self {
22        Self {
23            message: message.into(),
24            task: task.into(),
25            instance: instance.into(),
26            status: None,
27            title: None,
28            detail: None,
29            original_type: None,
30        }
31    }
32
33    fn with_status(mut self, status: Option<Value>) -> Self {
34        self.status = status;
35        self
36    }
37
38    fn with_title(mut self, title: Option<String>) -> Self {
39        self.title = title;
40        self
41    }
42
43    fn with_detail(mut self, detail: Option<String>) -> Self {
44        self.detail = detail;
45        self
46    }
47
48    fn with_original_type(mut self, original_type: Option<String>) -> Self {
49        self.original_type = original_type;
50        self
51    }
52
53    fn instance_opt(&self) -> Option<&str> {
54        if self.instance.is_empty() {
55            None
56        } else {
57            Some(&self.instance)
58        }
59    }
60}
61
62/// Error kind discriminator for WorkflowError
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ErrorKind {
65    Validation,
66    Expression,
67    Runtime,
68    Timeout,
69    Communication,
70    Authentication,
71    Authorization,
72    Configuration,
73}
74
75impl ErrorKind {
76    /// Returns the short type name (e.g., "validation", "runtime")
77    pub fn as_str(&self) -> &'static str {
78        match self {
79            ErrorKind::Validation => "validation",
80            ErrorKind::Expression => "expression",
81            ErrorKind::Runtime => "runtime",
82            ErrorKind::Timeout => "timeout",
83            ErrorKind::Communication => "communication",
84            ErrorKind::Authentication => "authentication",
85            ErrorKind::Authorization => "authorization",
86            ErrorKind::Configuration => "configuration",
87        }
88    }
89
90    /// Returns the full error type URI per the Serverless Workflow spec
91    pub fn type_uri(&self) -> &'static str {
92        match self {
93            ErrorKind::Validation => "https://serverlessworkflow.io/spec/1.0.0/errors/validation",
94            ErrorKind::Expression => "https://serverlessworkflow.io/spec/1.0.0/errors/expression",
95            ErrorKind::Runtime => "https://serverlessworkflow.io/spec/1.0.0/errors/runtime",
96            ErrorKind::Timeout => "https://serverlessworkflow.io/spec/1.0.0/errors/timeout",
97            ErrorKind::Communication => {
98                "https://serverlessworkflow.io/spec/1.0.0/errors/communication"
99            }
100            ErrorKind::Authentication => {
101                "https://serverlessworkflow.io/spec/1.0.0/errors/authentication"
102            }
103            ErrorKind::Authorization => {
104                "https://serverlessworkflow.io/spec/1.0.0/errors/authorization"
105            }
106            ErrorKind::Configuration => {
107                "https://serverlessworkflow.io/spec/1.0.0/errors/configuration"
108            }
109        }
110    }
111
112    /// Resolves an error type string to an ErrorKind.
113    /// Matches both the full URI (from ErrorTypes constants) and short names (suffix after last '/').
114    /// Returns ErrorKind::Runtime as the default for unknown types.
115    pub fn from_type_str(error_type: &str) -> Self {
116        const TYPE_MAP: &[(&str, ErrorKind)] = &[
117            ("validation", ErrorKind::Validation),
118            ("expression", ErrorKind::Expression),
119            ("timeout", ErrorKind::Timeout),
120            ("communication", ErrorKind::Communication),
121            ("authentication", ErrorKind::Authentication),
122            ("authorization", ErrorKind::Authorization),
123            ("configuration", ErrorKind::Configuration),
124        ];
125        TYPE_MAP
126            .iter()
127            .find(|(suffix, _)| {
128                error_type.ends_with(suffix)
129                    && (error_type.len() == suffix.len()
130                        || error_type
131                            .as_bytes()
132                            .get(error_type.len() - suffix.len() - 1)
133                            == Some(&b'/'))
134            })
135            .map(|(_, kind)| *kind)
136            .unwrap_or(ErrorKind::Runtime)
137    }
138}
139
140/// Runtime error for the Serverless Workflow engine
141#[derive(Debug, Clone)]
142pub struct WorkflowError {
143    kind: ErrorKind,
144    fields: ErrorFields,
145}
146
147impl std::error::Error for WorkflowError {}
148
149impl fmt::Display for WorkflowError {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        write!(
152            f,
153            "{} error in task '{}': {}",
154            self.kind.as_str(),
155            self.fields.task,
156            self.fields.message
157        )
158    }
159}
160
161impl WorkflowError {
162    /// Returns the error kind
163    pub fn kind(&self) -> ErrorKind {
164        self.kind
165    }
166
167    /// Returns a reference to the error fields
168    pub fn fields(&self) -> &ErrorFields {
169        &self.fields
170    }
171
172    /// Creates a validation error
173    pub fn validation(message: impl Into<String>, task: impl Into<String>) -> Self {
174        Self {
175            kind: ErrorKind::Validation,
176            fields: ErrorFields::new(message, task, ""),
177        }
178    }
179
180    /// Creates an expression error
181    pub fn expression(message: impl Into<String>, task: impl Into<String>) -> Self {
182        Self {
183            kind: ErrorKind::Expression,
184            fields: ErrorFields::new(message, task, ""),
185        }
186    }
187
188    /// Creates a runtime error
189    pub fn runtime(
190        message: impl Into<String>,
191        task: impl Into<String>,
192        instance: impl Into<String>,
193    ) -> Self {
194        Self {
195            kind: ErrorKind::Runtime,
196            fields: ErrorFields::new(message, task, instance),
197        }
198    }
199
200    /// Creates a runtime error without an instance (defaults to empty string)
201    pub fn runtime_simple(message: impl Into<String>, task: impl Into<String>) -> Self {
202        Self::runtime(message, task, "")
203    }
204
205    /// Creates a timeout error
206    /// Per the Serverless Workflow spec, timeout errors have status 408
207    pub fn timeout(message: impl Into<String>, task: impl Into<String>) -> Self {
208        Self {
209            kind: ErrorKind::Timeout,
210            fields: ErrorFields::new(message, task, "").with_status(Some(json!(408))),
211        }
212    }
213
214    /// Creates a communication error
215    pub fn communication(message: impl Into<String>, task: impl Into<String>) -> Self {
216        Self {
217            kind: ErrorKind::Communication,
218            fields: ErrorFields::new(message, task, ""),
219        }
220    }
221
222    /// Creates a communication error with an HTTP status code
223    pub fn communication_with_status(
224        message: impl Into<String>,
225        task: impl Into<String>,
226        status_code: u16,
227    ) -> Self {
228        Self {
229            kind: ErrorKind::Communication,
230            fields: ErrorFields::new(message, task, "").with_status(Some(Value::from(status_code))),
231        }
232    }
233
234    /// Creates a typed error from DSL error definition fields
235    pub fn typed(
236        error_type: &str,
237        detail: String,
238        task: String,
239        instance: String,
240        status: Option<Value>,
241        title: Option<String>,
242    ) -> Self {
243        let details = if detail.is_empty() {
244            None
245        } else {
246            Some(detail)
247        };
248        let fields = ErrorFields::new(details.clone().unwrap_or_default(), task, instance)
249            .with_status(status)
250            .with_title(title)
251            .with_detail(details)
252            .with_original_type(Some(error_type.to_string()));
253
254        let kind = ErrorKind::from_type_str(error_type);
255
256        Self { kind, fields }
257    }
258
259    /// Returns the error type as a full URI (prefers original type from DSL if available)
260    pub fn error_type(&self) -> &str {
261        self.fields
262            .original_type
263            .as_deref()
264            .unwrap_or(self.kind.type_uri())
265    }
266
267    /// Returns the short error type name (last segment of URI)
268    pub fn error_type_short(&self) -> &str {
269        if let Some(ot) = &self.fields.original_type {
270            if let Some(short) = ot.rsplit('/').next() {
271                return short;
272            }
273        }
274        self.kind.as_str()
275    }
276
277    /// Returns the task name associated with this error
278    pub fn task(&self) -> &str {
279        &self.fields.task
280    }
281
282    /// Returns the instance reference, if available
283    pub fn instance(&self) -> Option<&str> {
284        self.fields.instance_opt()
285    }
286
287    /// Returns the status code, if available
288    pub fn status(&self) -> Option<&Value> {
289        self.fields.status.as_ref()
290    }
291
292    /// Returns the title, if available
293    pub fn title(&self) -> Option<&str> {
294        self.fields.title.as_deref()
295    }
296
297    /// Returns the detail, if available
298    pub fn detail(&self) -> Option<&str> {
299        self.fields.detail.as_deref()
300    }
301
302    /// Converts the error to a JSON Value for use in expressions (e.g., $caughtError)
303    pub fn to_value(&self) -> Value {
304        let mut map = serde_json::Map::new();
305        map.insert(
306            "type".to_string(),
307            Value::String(self.error_type().to_string()),
308        );
309        if let Some(status) = self.status() {
310            map.insert("status".to_string(), status.clone());
311        }
312        if let Some(title) = self.title() {
313            map.insert("title".to_string(), Value::String(title.to_string()));
314        }
315        if let Some(detail) = self.detail() {
316            map.insert("details".to_string(), Value::String(detail.to_string()));
317        }
318        if let Some(instance) = self.instance() {
319            map.insert("instance".to_string(), Value::String(instance.to_string()));
320        }
321        Value::Object(map)
322    }
323
324    /// Sets the instance reference on the error if not already set
325    pub fn with_instance(self, instance: impl Into<String>) -> Self {
326        let new_instance = instance.into();
327        let inst = if self.fields.instance.is_empty() || self.fields.instance == "/" {
328            new_instance
329        } else {
330            self.fields.instance.clone()
331        };
332
333        Self {
334            kind: self.kind,
335            fields: ErrorFields {
336                message: self.fields.message,
337                task: self.fields.task,
338                instance: inst,
339                status: self.fields.status,
340                title: self.fields.title,
341                detail: self.fields.detail,
342                original_type: self.fields.original_type,
343            },
344        }
345    }
346}
347
348/// Result type alias for workflow operations
349pub type WorkflowResult<T> = Result<T, WorkflowError>;
350
351/// Serializes a value to JSON, mapping serialization errors to WorkflowError::runtime.
352/// This is a common pattern used across task runners.
353pub fn serialize_to_value<T: serde::Serialize>(
354    value: &T,
355    label: &str,
356    task_name: &str,
357) -> WorkflowResult<Value> {
358    serde_json::to_value(value).map_err(|e| {
359        WorkflowError::runtime(
360            format!("failed to serialize {}: {}", label, e),
361            task_name,
362            "",
363        )
364    })
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn test_error_type_validation() {
373        let err = WorkflowError::validation("invalid input", "task1");
374        assert_eq!(err.error_type_short(), "validation");
375        assert!(err.error_type().ends_with("/validation"));
376        assert_eq!(err.task(), "task1");
377    }
378
379    #[test]
380    fn test_error_type_expression() {
381        let err = WorkflowError::expression("bad jq", "task2");
382        assert_eq!(err.error_type_short(), "expression");
383    }
384
385    #[test]
386    fn test_error_type_runtime() {
387        let err = WorkflowError::runtime("something failed", "task3", "/ref");
388        assert_eq!(err.error_type_short(), "runtime");
389        assert_eq!(err.instance(), Some("/ref"));
390    }
391
392    #[test]
393    fn test_error_type_timeout() {
394        let err = WorkflowError::timeout("timed out", "task4");
395        assert_eq!(err.error_type_short(), "timeout");
396        assert!(err.instance().is_none());
397    }
398
399    #[test]
400    fn test_error_type_communication() {
401        let err = WorkflowError::communication("connection refused", "task5");
402        assert_eq!(err.error_type_short(), "communication");
403    }
404
405    #[test]
406    fn test_error_with_instance() {
407        let err = WorkflowError::runtime("invalid", "task1", "").with_instance("/ref/task1");
408        assert_eq!(err.error_type_short(), "runtime");
409        assert_eq!(err.instance(), Some("/ref/task1"));
410    }
411
412    #[test]
413    fn test_error_with_instance_preserves_type() {
414        let err = WorkflowError::timeout("timed out", "task1").with_instance("/ref/task1");
415        assert_eq!(err.error_type_short(), "timeout");
416        assert_eq!(err.instance(), Some("/ref/task1"));
417    }
418
419    #[test]
420    fn test_error_task_name() {
421        let err = WorkflowError::timeout("timeout", "myTask");
422        assert_eq!(err.task(), "myTask");
423    }
424
425    #[test]
426    fn test_error_display() {
427        let err = WorkflowError::validation("bad input", "task1");
428        let msg = format!("{}", err);
429        assert!(msg.contains("bad input"));
430        assert!(msg.contains("task1"));
431    }
432
433    #[test]
434    fn test_error_typed_with_status() {
435        let err = WorkflowError::typed(
436            "https://serverlessworkflow.io/spec/1.0.0/errors/transient",
437            "Something went wrong".to_string(),
438            "testTask".to_string(),
439            "/do/0/testTask".to_string(),
440            Some(Value::from(503)),
441            Some("Transient Error".to_string()),
442        );
443        assert_eq!(err.error_type_short(), "transient");
444        assert_eq!(err.status(), Some(&Value::from(503)));
445        assert_eq!(err.title(), Some("Transient Error"));
446        assert_eq!(err.detail(), Some("Something went wrong"));
447    }
448
449    #[test]
450    fn test_error_to_value() {
451        let err = WorkflowError::typed(
452            "https://serverlessworkflow.io/spec/1.0.0/errors/authentication",
453            "Auth failed".to_string(),
454            "authTask".to_string(),
455            "".to_string(),
456            Some(Value::from(401)),
457            Some("Auth Error".to_string()),
458        );
459        let val = err.to_value();
460        assert_eq!(
461            val["type"],
462            "https://serverlessworkflow.io/spec/1.0.0/errors/authentication"
463        );
464        assert_eq!(val["status"], 401);
465        assert_eq!(val["title"], "Auth Error");
466        assert_eq!(val["details"], "Auth failed");
467    }
468
469    #[test]
470    fn test_error_kind() {
471        let err = WorkflowError::timeout("timed out", "task1");
472        assert_eq!(err.kind(), ErrorKind::Timeout);
473    }
474}