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}