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}