Skip to main content

liminal/aion/
error.rs

1/// Error taxonomy for Aion's liminal integration surface.
2#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
3pub enum AionSurfaceError {
4    /// Selecting a worker or completing the dispatch conversation failed.
5    #[error("dispatch failed for workflow '{workflow_id}' on channel '{channel_name}': {message}")]
6    DispatchFailed {
7        /// Channel used for the dispatch conversation.
8        channel_name: String,
9        /// Workflow that requested the activity dispatch.
10        workflow_id: String,
11        /// Human-readable failure detail.
12        message: String,
13    },
14
15    /// A linked worker process exited while handling work.
16    #[error(
17        "worker '{worker_id}' crashed while serving workflow '{workflow_id}' on channel '{channel_name}': {message}"
18    )]
19    WorkerCrashed {
20        /// Channel whose linked worker exited.
21        channel_name: String,
22        /// Workflow affected by the worker exit.
23        workflow_id: String,
24        /// Linked worker process identifier.
25        worker_id: String,
26        /// Human-readable exit detail.
27        message: String,
28    },
29
30    /// Publishing to a workflow signal channel failed.
31    #[error(
32        "signal delivery failed for signal '{signal_name}' to workflow '{workflow_id}' on channel '{channel_name}': {message}"
33    )]
34    SignalDeliveryFailed {
35        /// Signal channel that rejected delivery.
36        channel_name: String,
37        /// Workflow targeted by the signal.
38        workflow_id: String,
39        /// Signal name being delivered.
40        signal_name: String,
41        /// Human-readable failure detail.
42        message: String,
43    },
44
45    /// A signal payload did not match the workflow's declared signal type.
46    #[error(
47        "signal validation failed for signal '{signal_name}' to workflow '{workflow_id}' on channel '{channel_name}': {message}"
48    )]
49    SignalValidationFailed {
50        /// Signal channel that performed validation.
51        channel_name: String,
52        /// Workflow targeted by the signal.
53        workflow_id: String,
54        /// Signal name whose payload failed validation.
55        signal_name: String,
56        /// Human-readable validation detail.
57        message: String,
58    },
59
60    /// Publishing or subscribing to workflow history failed.
61    #[error(
62        "history streaming failed for workflow '{workflow_id}' on channel '{channel_name}': {message}"
63    )]
64    StreamingFailed {
65        /// History channel being published to or subscribed from.
66        channel_name: String,
67        /// Workflow whose history stream failed.
68        workflow_id: String,
69        /// Human-readable failure detail.
70        message: String,
71    },
72
73    /// A namespace, workflow id, or task queue input cannot form an Aion channel name.
74    #[error("invalid channel name input '{input}' for {part}: {message}")]
75    InvalidChannelName {
76        /// Name component that failed validation.
77        part: String,
78        /// Rejected input value.
79        input: String,
80        /// Human-readable validation detail.
81        message: String,
82    },
83
84    /// Creating or tearing down an Aion channel failed.
85    #[error("channel lifecycle error for channel '{channel_name}': {message}")]
86    ChannelLifecycleError {
87        /// Channel being created or torn down.
88        channel_name: String,
89        /// Human-readable failure detail.
90        message: String,
91    },
92}
93
94#[cfg(test)]
95mod tests {
96    use std::error::Error;
97
98    use super::super::{dispatch_channel, history_channel, signal_channel};
99    use super::AionSurfaceError;
100
101    fn assert_error_trait<E: Error>(_: &E) {}
102
103    #[test]
104    fn implements_std_error_and_debug() -> Result<(), AionSurfaceError> {
105        let channel_name = String::from(history_channel("prod", "wf-123")?);
106        let error = AionSurfaceError::ChannelLifecycleError {
107            channel_name,
108            message: "teardown failed".to_owned(),
109        };
110        let debug_output = format!("{error:?}");
111
112        assert_error_trait(&error);
113        assert!(error.source().is_none());
114        assert!(!debug_output.is_empty());
115
116        Ok(())
117    }
118
119    #[test]
120    fn dispatch_display_includes_channel_and_workflow_context() -> Result<(), AionSurfaceError> {
121        let channel_name = String::from(dispatch_channel("prod", "email-queue")?);
122        let error = AionSurfaceError::DispatchFailed {
123            channel_name: channel_name.clone(),
124            workflow_id: "wf-123".to_owned(),
125            message: "conversation failed".to_owned(),
126        };
127        let display = error.to_string();
128
129        assert!(display.contains(&channel_name));
130        assert!(display.contains("wf-123"));
131        assert!(display.contains("conversation failed"));
132
133        Ok(())
134    }
135
136    #[test]
137    fn worker_crash_display_includes_channel_workflow_and_worker_context()
138    -> Result<(), AionSurfaceError> {
139        let channel_name = String::from(dispatch_channel("prod", "email-queue")?);
140        let error = AionSurfaceError::WorkerCrashed {
141            channel_name: channel_name.clone(),
142            workflow_id: "wf-123".to_owned(),
143            worker_id: "worker-9".to_owned(),
144            message: "linked process exited".to_owned(),
145        };
146        let display = error.to_string();
147
148        assert!(display.contains(&channel_name));
149        assert!(display.contains("wf-123"));
150        assert!(display.contains("worker-9"));
151        assert!(display.contains("linked process exited"));
152
153        Ok(())
154    }
155
156    #[test]
157    fn signal_display_includes_channel_workflow_and_signal_context() -> Result<(), AionSurfaceError>
158    {
159        let channel_name = String::from(signal_channel("prod", "wf-123")?);
160        let delivery = AionSurfaceError::SignalDeliveryFailed {
161            channel_name: channel_name.clone(),
162            workflow_id: "wf-123".to_owned(),
163            signal_name: "approve".to_owned(),
164            message: "publish failed".to_owned(),
165        };
166        let validation = AionSurfaceError::SignalValidationFailed {
167            channel_name: channel_name.clone(),
168            workflow_id: "wf-123".to_owned(),
169            signal_name: "approve".to_owned(),
170            message: "payload schema mismatch".to_owned(),
171        };
172
173        for display in [delivery.to_string(), validation.to_string()] {
174            assert!(display.contains(&channel_name));
175            assert!(display.contains("wf-123"));
176            assert!(display.contains("approve"));
177        }
178
179        Ok(())
180    }
181
182    #[test]
183    fn streaming_display_includes_channel_and_workflow_context() -> Result<(), AionSurfaceError> {
184        let channel_name = String::from(history_channel("prod", "wf-123")?);
185        let error = AionSurfaceError::StreamingFailed {
186            channel_name: channel_name.clone(),
187            workflow_id: "wf-123".to_owned(),
188            message: "subscribe failed".to_owned(),
189        };
190        let display = error.to_string();
191
192        assert!(display.contains(&channel_name));
193        assert!(display.contains("wf-123"));
194        assert!(display.contains("subscribe failed"));
195
196        Ok(())
197    }
198
199    #[test]
200    fn invalid_channel_name_display_identifies_invalid_input() {
201        let error = AionSurfaceError::InvalidChannelName {
202            part: "namespace".to_owned(),
203            input: "bad.ns".to_owned(),
204            message: "must not contain dots".to_owned(),
205        };
206        let display = error.to_string();
207
208        assert!(display.contains("namespace"));
209        assert!(display.contains("bad.ns"));
210        assert!(display.contains("must not contain dots"));
211    }
212}