Skip to main content

queuey_core/
error.rs

1//! Error types: [`enum@Error`] for infrastructure, [`JobError`] for handlers.
2
3use thiserror::Error;
4
5/// `Result` alias defaulting to this crate's [`enum@Error`].
6pub type Result<T, E = Error> = std::result::Result<T, E>;
7
8/// Infrastructure-level errors (serialization, transport, configuration).
9#[derive(Debug, Error)]
10pub enum Error {
11    /// A job payload or envelope could not be (de)serialized.
12    #[error("serialization error: {0}")]
13    Serde(#[from] serde_json::Error),
14
15    /// The transport failed.
16    #[error("backend error: {0}")]
17    Backend(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
18
19    /// A queue name that is not part of the queue set was used.
20    #[error("unknown queue `{0}`")]
21    UnknownQueue(String),
22
23    /// No handler is registered for a job type.
24    #[error("no handler registered for job type `{0}`")]
25    NoHandler(String),
26
27    /// Two handlers claimed the same `Job::NAME`.
28    #[error("handler for job type `{0}` registered twice")]
29    DuplicateHandler(String),
30
31    /// The worker or backend is shut down.
32    #[error("worker is shut down")]
33    ShutDown,
34
35    /// A consumer stream ended on its own, which means the backend went away.
36    #[error("consumer for queue `{0}` stopped unexpectedly")]
37    ConsumerStopped(String),
38}
39
40impl Error {
41    /// Wrap a transport error.
42    pub fn backend<E: std::error::Error + Send + Sync + 'static>(e: E) -> Self {
43        Self::Backend(Box::new(e))
44    }
45}
46
47/// Error returned by a [`crate::JobHandler`].
48#[derive(Debug, Error)]
49pub enum JobError {
50    /// Transient failure; the retry policy decides whether to retry.
51    #[error("job failed (retryable): {0}")]
52    Retryable(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
53    /// Permanent failure; go straight to dead-letter regardless of policy.
54    #[error("job failed (fatal): {0}")]
55    Fatal(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
56    /// Not a failure: the job could not run *yet* and must be tried again in `delay`.
57    ///
58    /// The motivating case is an external API answering `429 Too Many Requests` with
59    /// `Retry-After: 30`: nothing went wrong. The job has to wait exactly that
60    /// long and then run *before* the backlog that piled up meanwhile.
61    ///
62    /// Unlike [`JobError::Retryable`]:
63    ///
64    /// * [`crate::Envelope::attempt`] is **unchanged**, so a deferral never burns down
65    ///   the retry budget and a job may defer itself indefinitely;
66    /// * the retry policy is **not consulted**: neither its backoff (the handler
67    ///   states the delay) nor its `max_attempts` (nothing failed, so nothing is
68    ///   dead-lettered);
69    /// * [`crate::Envelope::deferrals`] is incremented and the envelope comes back with
70    ///   the highest priority its queue supports, ahead of normally enqueued work;
71    /// * the worker logs it at `INFO`, not `WARN`/`ERROR`.
72    ///
73    /// There is no built-in cap: a handler that wants one inspects
74    /// [`crate::JobContext::deferrals`] and returns [`JobError::Fatal`] instead.
75    #[error("job deferred for {delay:?}: {reason}")]
76    Deferred {
77        /// How long the job must wait before it is delivered again.
78        delay: std::time::Duration,
79        /// Why it was deferred, for logs. Not part of any control flow.
80        reason: String,
81    },
82}
83
84impl JobError {
85    /// Wrap `e` as a transient failure.
86    pub fn retryable<E: std::error::Error + Send + Sync + 'static>(e: E) -> Self {
87        Self::Retryable(Box::new(e))
88    }
89    /// Wrap `e` as a permanent failure.
90    pub fn fatal<E: std::error::Error + Send + Sync + 'static>(e: E) -> Self {
91        Self::Fatal(Box::new(e))
92    }
93    /// Transient failure with a plain message.
94    pub fn retryable_msg(msg: impl Into<String>) -> Self {
95        Self::Retryable(msg.into().into())
96    }
97    /// Permanent failure with a plain message.
98    pub fn fatal_msg(msg: impl Into<String>) -> Self {
99        Self::Fatal(msg.into().into())
100    }
101    /// Defer the job by `delay` with the generic reason `"deferred"`.
102    ///
103    /// Not a failure: the attempt counter is untouched and the retry policy is not
104    /// consulted. See [`JobError::Deferred`].
105    pub fn deferred(delay: std::time::Duration) -> Self {
106        Self::Deferred {
107            delay,
108            reason: "deferred".to_owned(),
109        }
110    }
111    /// Defer the job by `delay`, recording why (`"rate limited: Retry-After 30s"`).
112    ///
113    /// Not a failure: the attempt counter is untouched and the retry policy is not
114    /// consulted. See [`JobError::Deferred`].
115    pub fn deferred_msg(delay: std::time::Duration, msg: impl Into<String>) -> Self {
116        Self::Deferred {
117            delay,
118            reason: msg.into(),
119        }
120    }
121}
122
123/// Convenience: any `std::error::Error` becomes a retryable job error.
124impl From<Box<dyn std::error::Error + Send + Sync + 'static>> for JobError {
125    fn from(e: Box<dyn std::error::Error + Send + Sync + 'static>) -> Self {
126        Self::Retryable(e)
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use std::time::Duration;
134
135    #[test]
136    fn deferred_uses_a_generic_reason() {
137        let err = JobError::deferred(Duration::from_secs(30));
138        match &err {
139            JobError::Deferred { delay, reason } => {
140                assert_eq!(*delay, Duration::from_secs(30));
141                assert_eq!(reason, "deferred");
142            }
143            other => panic!("unexpected error: {other}"),
144        }
145        assert_eq!(err.to_string(), "job deferred for 30s: deferred");
146    }
147
148    #[test]
149    fn deferred_msg_keeps_the_message() {
150        let err = JobError::deferred_msg(Duration::from_millis(1_500), "rate limited");
151        match &err {
152            JobError::Deferred { delay, reason } => {
153                assert_eq!(*delay, Duration::from_millis(1_500));
154                assert_eq!(reason, "rate limited");
155            }
156            other => panic!("unexpected error: {other}"),
157        }
158        assert_eq!(err.to_string(), "job deferred for 1.5s: rate limited");
159    }
160
161    #[test]
162    fn a_deferral_carries_no_source_error() {
163        use std::error::Error as _;
164        assert!(
165            JobError::deferred(Duration::from_secs(1))
166                .source()
167                .is_none()
168        );
169        assert!(JobError::retryable_msg("boom").source().is_some());
170    }
171}