eventfold_es/error.rs
1//! Crate-level error types for command execution and state retrieval.
2
3/// Error returned when executing a command against an aggregate fails.
4///
5/// Generic over `E`, the domain-specific error type that the aggregate's
6/// command handler may produce (e.g., "insufficient funds").
7///
8/// # Type Parameters
9///
10/// * `E` - Domain error type, must implement `Error + Send + Sync + 'static`
11#[derive(Debug, thiserror::Error)]
12pub enum ExecuteError<E: std::error::Error + Send + Sync + 'static> {
13 /// Command rejected by aggregate logic.
14 ///
15 /// Wraps the domain-specific error returned from the aggregate's
16 /// command handler, forwarding its `Display` and `Error` impls.
17 #[error(transparent)]
18 Domain(E),
19
20 /// Optimistic concurrency retries exhausted.
21 ///
22 /// The command was retried the maximum number of times but each
23 /// attempt encountered a version conflict with a concurrent writer.
24 #[error("optimistic concurrency conflict: retries exhausted")]
25 Conflict,
26
27 /// Disk I/O failure.
28 ///
29 /// An underlying filesystem or storage-layer I/O error occurred
30 /// while loading or persisting events.
31 #[error("I/O error: {0}")]
32 Io(#[from] std::io::Error),
33
34 /// Actor thread exited unexpectedly.
35 ///
36 /// The background actor that owns this aggregate has shut down,
37 /// so no further commands can be processed.
38 #[error("aggregate actor is no longer running")]
39 ActorGone,
40}
41
42/// Error returned when reading the current state of an aggregate fails.
43#[derive(Debug, thiserror::Error)]
44pub enum StateError {
45 /// Disk I/O failure.
46 ///
47 /// An underlying filesystem or storage-layer I/O error occurred
48 /// while loading events to rebuild the aggregate state.
49 #[error("I/O error: {0}")]
50 Io(#[from] std::io::Error),
51
52 /// Actor thread exited unexpectedly.
53 ///
54 /// The background actor that owns this aggregate has shut down,
55 /// so its state can no longer be queried.
56 #[error("aggregate actor is no longer running")]
57 ActorGone,
58}
59
60/// Errors that can occur when dispatching a command.
61///
62/// Produced by the dispatch layer ([`CommandBus`](crate::CommandBus) or
63/// process manager dispatch) when a command cannot be routed to or executed
64/// by the target aggregate.
65#[derive(Debug, thiserror::Error)]
66pub enum DispatchError {
67 /// No dispatcher registered for the target aggregate type.
68 #[error("unknown aggregate type: {0}")]
69 UnknownAggregateType(String),
70
71 /// No route registered for the command type.
72 ///
73 /// Returned by [`CommandBus::dispatch`](crate::CommandBus::dispatch) when
74 /// the command's concrete type has not been registered via `register::<A>()`.
75 #[error("no route registered for command type")]
76 UnknownCommand,
77
78 /// The command JSON could not be deserialized into the target
79 /// aggregate's command type.
80 #[error("command deserialization failed: {0}")]
81 Deserialization(serde_json::Error),
82
83 /// The target aggregate's command handler rejected the command or
84 /// an I/O error occurred during execution.
85 #[error("command execution failed: {0}")]
86 Execution(Box<dyn std::error::Error + Send + Sync>),
87
88 /// An I/O error occurred during dispatch (e.g. directory creation).
89 #[error("I/O error: {0}")]
90 Io(#[from] std::io::Error),
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 /// A minimal domain error for testing `ExecuteError<E>`.
98 #[derive(Debug, thiserror::Error)]
99 #[error("test domain error")]
100 struct TestDomainError;
101
102 #[test]
103 fn execute_error_domain_displays_inner() {
104 let err: ExecuteError<TestDomainError> = ExecuteError::Domain(TestDomainError);
105 assert_eq!(err.to_string(), "test domain error");
106 }
107
108 #[test]
109 fn execute_error_conflict_display() {
110 let err: ExecuteError<TestDomainError> = ExecuteError::Conflict;
111 assert_eq!(
112 err.to_string(),
113 "optimistic concurrency conflict: retries exhausted"
114 );
115 }
116
117 #[test]
118 fn execute_error_io_from_conversion() {
119 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
120 let err: ExecuteError<TestDomainError> = ExecuteError::from(io_err);
121 assert!(err.to_string().contains("file missing"));
122 }
123
124 #[test]
125 fn execute_error_actor_gone_display() {
126 let err: ExecuteError<TestDomainError> = ExecuteError::ActorGone;
127 assert_eq!(err.to_string(), "aggregate actor is no longer running");
128 }
129
130 #[test]
131 fn state_error_io_from_conversion() {
132 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
133 let err: StateError = StateError::from(io_err);
134 assert!(err.to_string().contains("access denied"));
135 }
136
137 #[test]
138 fn state_error_actor_gone_display() {
139 let err = StateError::ActorGone;
140 assert_eq!(err.to_string(), "aggregate actor is no longer running");
141 }
142
143 // Verify `Send + Sync` bounds are satisfied so errors can cross thread
144 // boundaries, which is required for use with `tokio` channels.
145 const _: () = {
146 #[allow(dead_code)]
147 fn assert_send_sync<T: Send + Sync>() {}
148
149 #[allow(dead_code)]
150 fn check() {
151 assert_send_sync::<ExecuteError<TestDomainError>>();
152 assert_send_sync::<StateError>();
153 }
154 };
155}