Skip to main content

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}