Skip to main content

hexeract_core/
error.rs

1use std::time::Duration;
2use thiserror::Error;
3
4/// Top-level error type for the Hexeract framework.
5///
6/// This enum is marked `#[non_exhaustive]` so that new variants can be added
7/// in minor versions without breaking downstream `match` arms.
8#[derive(Debug, Error)]
9#[non_exhaustive]
10pub enum HexeractError {
11    /// No handler was registered for the given message type.
12    #[error("no handler registered for `{message_type}`")]
13    #[non_exhaustive]
14    HandlerNotFound {
15        /// The fully-qualified type name of the unregistered command, query or
16        /// notification.
17        message_type: &'static str,
18    },
19
20    /// A handler returned an error. The original error is preserved as source.
21    #[error("handler failed: {source}")]
22    #[non_exhaustive]
23    HandlerFailed {
24        /// The original error returned by the handler.
25        #[source]
26        source: Box<dyn std::error::Error + Send + Sync>,
27    },
28
29    /// A dispatch exceeded its configured deadline.
30    #[error("dispatch of `{type_name}` timed out after {duration:?}")]
31    #[non_exhaustive]
32    Timeout {
33        /// Fully-qualified type name of the message being dispatched.
34        type_name: &'static str,
35        /// Configured timeout that was exceeded.
36        duration: Duration,
37    },
38
39    /// A dispatch produced a value that could not be downcast to the expected
40    /// output type.
41    ///
42    /// This indicates a short-circuiting [`Middleware`](crate::middleware::Middleware)
43    /// boxed a value whose type is not the message's `Output`. A correct
44    /// short-circuit must box exactly the dispatched message's output type.
45    #[error("dispatch produced a value that is not the expected output type `{expected}`")]
46    #[non_exhaustive]
47    DowncastFailed {
48        /// Fully-qualified name of the output type the dispatch expected.
49        expected: &'static str,
50    },
51
52    /// The boxed input passed to an erased handler could not be downcast to the
53    /// concrete message type the handler was registered for.
54    ///
55    /// Under normal operation this variant is unreachable: the mediator only
56    /// boxes a value of type `C`, `Q`, or `Arc<N>` immediately before calling
57    /// the matching typed adapter, so the downcast always succeeds. A mismatch
58    /// would indicate an internal invariant violation in the dispatch plumbing.
59    #[error("input downcast failed: expected `{expected}`")]
60    #[non_exhaustive]
61    InputDowncastFailed {
62        /// Fully-qualified name of the message type the adapter expected.
63        expected: &'static str,
64    },
65
66    /// A dispatch was cancelled before the handler produced a result because
67    /// its [`HandlerContext`](crate::HandlerContext) cancellation token fired.
68    ///
69    /// The dispatch pipeline observes the token before each middleware and
70    /// before the terminal handler, so a middleware that cancels the token
71    /// short-circuits the rest of the chain. Handlers and middlewares may
72    /// also raise this variant themselves via [`HexeractError::cancelled`].
73    #[error("dispatch of `{type_name}` was cancelled")]
74    #[non_exhaustive]
75    Cancelled {
76        /// Fully-qualified type name of the message whose dispatch was cancelled.
77        type_name: &'static str,
78    },
79
80    /// One or more notification handlers failed during a `publish` fan-out.
81    ///
82    /// Every handler runs regardless of its siblings; the failures are
83    /// collected here in registration order, each retaining the handler's
84    /// typed error and its `source` chain. Prefer matching this variant and
85    /// inspecting [`NotificationFailure`] over parsing the message when a
86    /// caller needs to recover an individual handler's error.
87    #[error("publish: {} of {total} handlers failed: {}", failures.len(), render_publish_failures(failures))]
88    #[non_exhaustive]
89    PublishFailed {
90        /// Fully-qualified type name of the published notification.
91        notification_type: &'static str,
92        /// Total number of handlers the notification fanned out to.
93        total: usize,
94        /// Per-handler failures, in registration order.
95        failures: Vec<NotificationFailure>,
96    },
97
98    /// A generic dispatch-level error with a human-readable message.
99    ///
100    /// Reserved as a last resort for cases that have no dedicated structured
101    /// variant, such as aggregating several notification handler failures into
102    /// one message, or reporting a framework invariant violation. Prefer a
103    /// specific variant ([`HandlerNotFound`](Self::HandlerNotFound),
104    /// [`DowncastFailed`](Self::DowncastFailed), [`Cancelled`](Self::Cancelled),
105    /// ...) whenever one applies.
106    #[error("dispatch error: {0}")]
107    Dispatch(String),
108}
109
110/// One notification handler that failed during a notification `publish`
111/// fan-out, paired with the typed error it returned.
112///
113/// The [`error`](Self::error) field keeps the full [`HexeractError`], so its
114/// `source` chain stays intact for callers that need to recover the original
115/// failure rather than a flattened string. Values are exposed through
116/// [`HexeractError::PublishFailed`].
117#[derive(Debug)]
118pub struct NotificationFailure {
119    /// Fully-qualified type name of the handler that failed.
120    pub handler: &'static str,
121    /// Typed error the handler returned, with its `source` chain intact.
122    pub error: HexeractError,
123}
124
125/// Renders aggregated notification failures as `handler: error` segments
126/// joined with `; `, used by the [`HexeractError::PublishFailed`] `Display`.
127fn render_publish_failures(failures: &[NotificationFailure]) -> String {
128    failures
129        .iter()
130        .map(|failure| format!("{}: {}", failure.handler, failure.error))
131        .collect::<Vec<_>>()
132        .join("; ")
133}
134
135impl HexeractError {
136    /// Builds a [`HexeractError::HandlerNotFound`] from the fully-qualified
137    /// type name of the unregistered message. This is the only way to
138    /// construct the variant from outside this crate, since it is marked
139    /// `#[non_exhaustive]`.
140    #[must_use]
141    pub fn handler_not_found(message_type: &'static str) -> Self {
142        Self::HandlerNotFound { message_type }
143    }
144
145    /// Wraps any `Send + Sync` error as a [`HexeractError::HandlerFailed`].
146    pub fn handler_failed(source: impl std::error::Error + Send + Sync + 'static) -> Self {
147        Self::HandlerFailed {
148            source: Box::new(source),
149        }
150    }
151
152    /// Builds a [`HexeractError::Timeout`] from the dispatched message type
153    /// name and the timeout that was exceeded. This is the only way to
154    /// construct the variant from outside this crate, since it is marked
155    /// `#[non_exhaustive]`.
156    #[must_use]
157    pub fn timeout(type_name: &'static str, duration: Duration) -> Self {
158        Self::Timeout {
159            type_name,
160            duration,
161        }
162    }
163
164    /// Builds a [`HexeractError::DowncastFailed`] from the fully-qualified name
165    /// of the output type the dispatch expected. This is the only way to
166    /// construct the variant from outside this crate, since it is marked
167    /// `#[non_exhaustive]`.
168    #[must_use]
169    pub fn downcast_failed(expected: &'static str) -> Self {
170        Self::DowncastFailed { expected }
171    }
172
173    /// Builds a [`HexeractError::InputDowncastFailed`] from the fully-qualified
174    /// name of the message type the erased adapter expected. This is the only
175    /// way to construct the variant from outside this crate, since it is marked
176    /// `#[non_exhaustive]`.
177    #[must_use]
178    pub fn input_downcast_failed(expected: &'static str) -> Self {
179        Self::InputDowncastFailed { expected }
180    }
181
182    /// Builds a [`HexeractError::Cancelled`] from the fully-qualified name of
183    /// the message whose dispatch was cancelled. This is the only way to
184    /// construct the variant from outside this crate, since it is marked
185    /// `#[non_exhaustive]`.
186    #[must_use]
187    pub fn cancelled(type_name: &'static str) -> Self {
188        Self::Cancelled { type_name }
189    }
190
191    /// Builds a [`HexeractError::PublishFailed`] from the published
192    /// notification's type name, the total number of handlers it fanned out
193    /// to, and the per-handler failures collected in registration order. This
194    /// is the only way to construct the variant from outside this crate, since
195    /// it is marked `#[non_exhaustive]`.
196    #[must_use]
197    pub fn publish_failed(
198        notification_type: &'static str,
199        total: usize,
200        failures: Vec<NotificationFailure>,
201    ) -> Self {
202        Self::PublishFailed {
203            notification_type,
204            total,
205            failures,
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn handler_not_found_display() {
216        let err = HexeractError::HandlerNotFound {
217            message_type: "RegisterUser",
218        };
219        assert_eq!(err.to_string(), "no handler registered for `RegisterUser`");
220    }
221
222    #[test]
223    fn cancelled_names_the_message_type() {
224        let err = HexeractError::cancelled("my::RegisterUser");
225        let rendered = err.to_string();
226        assert!(rendered.contains("RegisterUser"));
227        assert!(rendered.contains("cancelled"));
228        assert!(
229            matches!(err, HexeractError::Cancelled { type_name } if type_name == "my::RegisterUser")
230        );
231    }
232
233    #[test]
234    fn timeout_display_shows_type_name_and_duration() {
235        let err = HexeractError::Timeout {
236            type_name: "my::RegisterUser",
237            duration: Duration::from_secs(5),
238        };
239        let rendered = err.to_string();
240        assert!(rendered.contains("RegisterUser"));
241        assert!(rendered.contains("5s"));
242    }
243
244    #[test]
245    fn handler_failed_preserves_source() {
246        let original = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
247        let err = HexeractError::handler_failed(original);
248        assert!(err.to_string().contains("handler failed"));
249        assert!(std::error::Error::source(&err).is_some());
250    }
251
252    #[test]
253    fn handler_not_found_names_the_message_type() {
254        let err = HexeractError::handler_not_found("my::RegisterUser");
255        assert!(
256            matches!(err, HexeractError::HandlerNotFound { message_type } if message_type == "my::RegisterUser")
257        );
258        assert!(err.to_string().contains("RegisterUser"));
259    }
260
261    #[test]
262    fn downcast_failed_names_the_expected_output_type() {
263        let err = HexeractError::downcast_failed("u32");
264        let rendered = err.to_string();
265        assert!(rendered.contains("u32"));
266        assert!(matches!(err, HexeractError::DowncastFailed { expected } if expected == "u32"));
267    }
268
269    #[test]
270    fn input_downcast_failed_names_the_expected_message_type() {
271        let err = HexeractError::input_downcast_failed("my::RegisterUser");
272        let rendered = err.to_string();
273        assert!(rendered.contains("my::RegisterUser"));
274        assert!(
275            matches!(err, HexeractError::InputDowncastFailed { expected } if expected == "my::RegisterUser")
276        );
277    }
278
279    #[test]
280    fn publish_failed_aggregates_typed_handler_errors() {
281        let failures = vec![
282            NotificationFailure {
283                handler: "my::AuditHandler",
284                error: HexeractError::cancelled("my::UserCreated"),
285            },
286            NotificationFailure {
287                handler: "my::EmailHandler",
288                error: HexeractError::Dispatch("smtp down".into()),
289            },
290        ];
291        let err = HexeractError::publish_failed("my::UserCreated", 3, failures);
292        let HexeractError::PublishFailed {
293            notification_type,
294            total,
295            failures,
296        } = err
297        else {
298            panic!("expected PublishFailed variant");
299        };
300        assert_eq!(notification_type, "my::UserCreated");
301        assert_eq!(total, 3);
302        assert_eq!(failures.len(), 2);
303        assert_eq!(failures[0].handler, "my::AuditHandler");
304        assert!(matches!(failures[1].error, HexeractError::Dispatch(_)));
305    }
306
307    #[test]
308    fn publish_failed_display_summarizes_count_and_handlers() {
309        let failures = vec![NotificationFailure {
310            handler: "my::AuditHandler",
311            error: HexeractError::Dispatch("boom".into()),
312        }];
313        let err = HexeractError::publish_failed("my::UserCreated", 3, failures);
314        let rendered = err.to_string();
315        assert!(rendered.starts_with("publish: 1 of 3 handlers failed"));
316        assert!(rendered.contains("my::AuditHandler"));
317        assert!(rendered.contains("boom"));
318    }
319
320    #[test]
321    fn publish_failed_preserves_underlying_handler_source() {
322        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
323        let failures = vec![NotificationFailure {
324            handler: "my::PersistHandler",
325            error: HexeractError::handler_failed(io),
326        }];
327        let err = HexeractError::publish_failed("my::UserCreated", 1, failures);
328        let HexeractError::PublishFailed { failures, .. } = err else {
329            panic!("expected PublishFailed variant");
330        };
331        assert!(std::error::Error::source(&failures[0].error).is_some());
332    }
333}