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}