Skip to main content

hexeract_outbox/
error.rs

1use std::time::Duration;
2
3use thiserror::Error;
4use uuid::Uuid;
5
6/// Errors raised by the outbox primitives, publishers and workers.
7///
8/// Marked `#[non_exhaustive]` so new variants can be added without a
9/// breaking change.
10#[derive(Debug, Error)]
11#[non_exhaustive]
12pub enum OutboxError {
13    /// The event payload could not be serialized or deserialized as JSON.
14    #[error("failed to (de)serialize event payload as JSON")]
15    Serialization(#[from] serde_json::Error),
16
17    /// The backend reported a database-level failure.
18    ///
19    /// The original error is preserved as a boxed source so callers can
20    /// downcast if they need typed access to the underlying driver error.
21    #[error("database error")]
22    Database(#[source] Box<dyn std::error::Error + Send + Sync>),
23
24    /// The worker polled an envelope whose `event_type` has no registered handler.
25    #[error("no handler registered for event type `{event_type}`")]
26    MissingHandler {
27        /// The unrouted event type read from the envelope.
28        event_type: String,
29    },
30
31    /// Reserved for future use. Not constructed by the current outbox
32    /// worker implementation.
33    ///
34    /// The worker today leaves exhausted events in the table (where they
35    /// are excluded from future polls by the `attempts < max_attempts`
36    /// predicate) or moves them to the dead-letter table when one is
37    /// configured. This variant is kept in the enum so a future release
38    /// can expose exhaustion as a typed error without a breaking change.
39    /// Do not match on it expecting to observe it in normal operation.
40    #[error("event {event_id} reached max retries after {attempts} attempts")]
41    MaxRetries {
42        /// Identifier of the event that exhausted its retry budget.
43        event_id: Uuid,
44        /// Number of attempts already consumed.
45        attempts: u32,
46    },
47
48    /// An envelope was decoded into the wrong event type.
49    ///
50    /// Returned when a caller invokes [`crate::OutboxEnvelope::decode`]
51    /// with a type whose [`crate::Event::EVENT_TYPE`] does not match the
52    /// envelope's `event_type` field. Typically the sign of a
53    /// router or registry misconfiguration on the caller side.
54    #[error("envelope carries event_type `{actual}` but decode requested `{expected}`")]
55    TypeMismatch {
56        /// Event type requested by the caller (`E::EVENT_TYPE`).
57        expected: &'static str,
58        /// Event type actually stored in the envelope.
59        actual: String,
60    },
61
62    /// The connection pool did not yield a connection within the configured
63    /// timeout.
64    ///
65    /// This is a transient condition: the pool is under pressure but the
66    /// database itself may be healthy. The outbox worker retries automatically
67    /// after [`OutboxWorkerConfig::poll_interval`]. Application code that
68    /// observes this variant can implement back-pressure or circuit-breaking.
69    ///
70    /// To prevent indefinite blocking, configure an acquire timeout on the
71    /// pool (e.g. `sqlx::pool::PoolOptions::acquire_timeout`).
72    ///
73    /// [`OutboxWorkerConfig::poll_interval`]: crate::OutboxWorkerConfig::poll_interval
74    #[error("connection pool acquire timed out")]
75    PoolTimeout,
76
77    /// The handler did not complete within
78    /// [`OutboxWorkerConfig::dispatch_timeout`].
79    ///
80    /// The worker enforces `dispatch_timeout` as a hard deadline around each
81    /// handler invocation. When it elapses the dispatch is treated as a failed
82    /// attempt (recorded via [`crate::OutboxStore::mark_failed`] and retried or
83    /// dead-lettered like any other error) and the handler's cancellation token
84    /// is signalled so cooperative handlers can unwind.
85    ///
86    /// [`OutboxWorkerConfig::dispatch_timeout`]: crate::OutboxWorkerConfig::dispatch_timeout
87    #[error("handler for event {event_id} ({event_type}) timed out after {timeout:?}")]
88    DispatchTimeout {
89        /// Identifier of the event whose handler timed out.
90        event_id: Uuid,
91        /// The unrouted event type read from the envelope.
92        event_type: String,
93        /// The configured dispatch timeout that elapsed.
94        timeout: Duration,
95    },
96
97    /// An invariant of the outbox machinery was violated.
98    ///
99    /// Signals a bug in the framework itself, not a recoverable error.
100    /// Report occurrences upstream.
101    #[error("internal outbox error: {0}")]
102    Internal(String),
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn serialization_error_is_built_from_serde_json() {
111        let invalid_json = b"not json";
112        let serde_error: serde_json::Error =
113            serde_json::from_slice::<serde_json::Value>(invalid_json).unwrap_err();
114        let error: OutboxError = serde_error.into();
115        assert!(matches!(error, OutboxError::Serialization(_)));
116    }
117
118    #[test]
119    fn database_error_preserves_source_chain() {
120        let inner = std::io::Error::other("disk on fire");
121        let error = OutboxError::Database(Box::new(inner));
122        let source = std::error::Error::source(&error).expect("source must be set");
123        assert_eq!(source.to_string(), "disk on fire");
124    }
125
126    #[test]
127    fn missing_handler_message_includes_event_type() {
128        let error = OutboxError::MissingHandler {
129            event_type: "users.registered".to_owned(),
130        };
131        assert!(error.to_string().contains("users.registered"));
132    }
133
134    #[test]
135    fn max_retries_message_includes_event_id_and_count() {
136        let event_id = Uuid::from_u128(7);
137        let error = OutboxError::MaxRetries {
138            event_id,
139            attempts: 5,
140        };
141        let message = error.to_string();
142        assert!(message.contains(&event_id.to_string()));
143        assert!(message.contains('5'));
144    }
145
146    #[test]
147    fn pool_timeout_has_descriptive_message() {
148        let error = OutboxError::PoolTimeout;
149        assert!(error.to_string().contains("timed out"));
150    }
151}