Skip to main content

queuey_rabbitmq/
reconnect.rs

1//! When and how hard this backend tries to get its connection back.
2//!
3//! [`ReconnectPolicy`] is the decision, [`BackoffPolicy`] the built-in answer to
4//! it. Everything in this module is about *pacing*: whether to try again, and
5//! how long to wait first. What actually gets rebuilt is
6//! [`ConnectionHandle`](crate::connection::ConnectionHandle)'s business.
7
8use std::{fmt::Debug, sync::Arc, time::Duration};
9
10use queuey_core::Backoff;
11
12/// Base delay of [`BackoffPolicy::default`]'s backoff.
13const DEFAULT_BASE: Duration = Duration::from_millis(500);
14
15/// Ceiling of [`BackoffPolicy::default`]'s backoff.
16///
17/// Half a minute is long enough that a broker that is down for an hour is
18/// retried a hundred-odd times rather than a hundred thousand, and short enough
19/// that a worker is back within half a minute of the broker returning.
20const DEFAULT_MAX: Duration = Duration::from_secs(30);
21
22/// What this backend is trying to rebuild.
23///
24/// Both share one policy, but they fail for different reasons and a policy is
25/// allowed to treat them differently. A connection fails because the broker is
26/// unreachable, and waiting is usually the only option. A resubscribe fails on a
27/// *live* connection, most often because the queue is not there, and no amount
28/// of waiting fixes a queue an operator deleted: that is the case worth
29/// giving up on.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum Rebuilding {
32    /// The AMQP connection itself.
33    Connection,
34    /// A consumer's subscription, on a connection that is already live.
35    Consumer,
36}
37
38/// The state a [`ReconnectPolicy`] decides on.
39///
40/// Non-exhaustive: it gains fields as the backend learns to report more, and an
41/// existing policy keeps compiling.
42#[derive(Clone, Copy, Debug)]
43#[non_exhaustive]
44pub struct Attempt<'a> {
45    /// Consecutive failures so far, resetting on every success.
46    ///
47    /// `0` on the first attempt after a drop, when nothing has failed yet: a
48    /// policy that returns [`Duration::ZERO`] for `0` retries immediately, which
49    /// is what [`BackoffPolicy`] does, because a failover is often complete by
50    /// the time the client notices.
51    pub failures: u32,
52
53    /// What failed last, or [`None`] when `failures` is `0`.
54    ///
55    /// Deliberately a plain [`std::error::Error`] rather than a concrete type:
56    /// the two [`Rebuilding`] cases fail with different error types, and a
57    /// policy that wants the detail can `downcast_ref` to
58    /// [`lapin::Error`](crate::lapin::Error) or
59    /// [`queuey_core::Error`]. Most policies only read `failures`.
60    pub error: Option<&'a (dyn std::error::Error + 'static)>,
61
62    /// Which of the two things is being rebuilt.
63    pub rebuilding: Rebuilding,
64}
65
66impl<'a> Attempt<'a> {
67    /// An attempt that has not failed yet.
68    ///
69    /// The backend builds these; they are public so you can unit-test a
70    /// [`ReconnectPolicy`] of your own. This type is `#[non_exhaustive]`, so
71    /// these constructors are the only way to make one.
72    #[must_use]
73    pub fn first(rebuilding: Rebuilding) -> Self {
74        Self {
75            failures: 0,
76            error: None,
77            rebuilding,
78        }
79    }
80
81    /// An attempt after `failures` consecutive failures, the last being `error`.
82    #[must_use]
83    pub fn after(
84        rebuilding: Rebuilding,
85        failures: u32,
86        error: &'a (dyn std::error::Error + 'static),
87    ) -> Self {
88        Self {
89            failures,
90            error: Some(error),
91            rebuilding,
92        }
93    }
94}
95
96/// How a [`RabbitMqBackend`](crate::RabbitMqBackend) paces its recovery of a
97/// lost connection.
98///
99/// One method, asked before *every* attempt including the first: return how long
100/// to wait, or [`None`] to give up. Giving up surfaces as
101/// [`Error::Backend`](queuey_core::Error::Backend) on whatever operation asked
102/// for the connection, and ends consumer streams, so
103/// [`Worker::run`](queuey_core::Worker::run) returns as it did before
104/// reconnection existed.
105///
106/// [`BackoffPolicy`] covers the usual cases (a backoff curve and an optional
107/// attempt limit) and is the default. Implement this directly when the decision
108/// needs something a curve cannot express: a circuit breaker, a schedule, a
109/// budget shared with the rest of the process, or a different answer for an
110/// authentication failure than for a refused connection.
111///
112/// ```
113/// use std::time::Duration;
114/// use queuey_rabbitmq::{Attempt, Rebuilding, ReconnectPolicy};
115///
116/// /// Waits a flat second, but never retries a consumer more than twice:
117/// /// a subscription that fails on a live connection is usually a deleted
118/// /// queue, and waiting does not bring one back.
119/// #[derive(Debug)]
120/// struct Impatient;
121///
122/// impl ReconnectPolicy for Impatient {
123///     fn next_delay(&self, attempt: Attempt<'_>) -> Option<Duration> {
124///         if attempt.rebuilding == Rebuilding::Consumer && attempt.failures >= 2 {
125///             return None;
126///         }
127///         Some(Duration::from_secs(1))
128///     }
129/// }
130///
131/// assert_eq!(
132///     Impatient.next_delay(Attempt::first(Rebuilding::Connection)),
133///     Some(Duration::from_secs(1)),
134/// );
135/// ```
136///
137/// Implementations are shared across tasks and consulted from several at once,
138/// hence `Send + Sync`. `Debug` is required because
139/// [`RabbitMqOptions`](crate::RabbitMqOptions) is `Debug`, and a policy that
140/// prints as nothing would make that output a lie.
141pub trait ReconnectPolicy: Send + Sync + Debug {
142    /// How long to wait before making `attempt`, or [`None`] to stop trying.
143    ///
144    /// Called before every attempt, `attempt.failures == 0` included, so a
145    /// policy controls the first try as well as the retries: returning
146    /// [`Duration::ZERO`] there attempts immediately, and returning [`None`]
147    /// there refuses to reconnect at all.
148    ///
149    /// Must not block: it is called from the task that is holding up every other
150    /// publisher waiting on the connection.
151    fn next_delay(&self, attempt: Attempt<'_>) -> Option<Duration>;
152}
153
154/// The built-in [`ReconnectPolicy`]: a [`Backoff`] curve plus an optional cap on
155/// consecutive attempts.
156///
157/// A dropped connection is the normal case, not the exceptional one: brokers are
158/// restarted for upgrades, failed over, and partitioned from their clients by
159/// the network in between. The default therefore retries **forever**, on the
160/// theory that a worker process which outlives its broker's restart is worth
161/// more than one which exits and waits for a supervisor.
162///
163/// ```
164/// use queuey_rabbitmq::BackoffPolicy;
165///
166/// // Give up after ten tries instead of retrying forever.
167/// let policy = BackoffPolicy::default().max_attempts(Some(10));
168/// assert_eq!(policy.max_attempts, Some(10));
169/// ```
170///
171/// Set [`RabbitMqOptions::reconnect`](crate::RabbitMqOptions::reconnect) to
172/// [`None`] to turn reconnection off entirely and get the original fail-fast
173/// behaviour back.
174#[derive(Clone, Debug)]
175pub struct BackoffPolicy {
176    /// How many consecutive attempts to make before giving up, or [`None`] to
177    /// keep trying indefinitely.
178    ///
179    /// Defaults to [`None`]. The count is of *consecutive* failures: it resets
180    /// the moment a connection succeeds, so a process that reconnects once an
181    /// hour for a year never exhausts a limit of three. `Some(0)` never
182    /// reconnects at all, which is [`RabbitMqOptions::reconnect`]`(None)` the
183    /// long way round.
184    ///
185    /// [`RabbitMqOptions::reconnect`]: crate::RabbitMqOptions::reconnect
186    pub max_attempts: Option<u32>,
187
188    /// Delay between attempts, as a function of how many have failed.
189    ///
190    /// Defaults to exponential with full jitter: 500ms base, doubling, capped at
191    /// 30 seconds. Jitter matters more here than in a job backoff: every worker
192    /// in a fleet loses its connection at the same instant when a broker goes
193    /// down, and an unjittered backoff would have all of them knock on the door
194    /// in lockstep for as long as the outage lasts.
195    pub backoff: Backoff,
196}
197
198impl Default for BackoffPolicy {
199    fn default() -> Self {
200        Self {
201            max_attempts: None,
202            backoff: Backoff::Exponential {
203                base: DEFAULT_BASE,
204                factor: 2.0,
205                max: DEFAULT_MAX,
206                jitter: true,
207            },
208        }
209    }
210}
211
212impl BackoffPolicy {
213    /// Limit the number of consecutive attempts, or [`None`] for no limit.
214    #[must_use]
215    pub fn max_attempts(mut self, attempts: Option<u32>) -> Self {
216        self.max_attempts = attempts;
217        self
218    }
219
220    /// Replace the delay schedule between attempts.
221    #[must_use]
222    pub fn backoff(mut self, backoff: Backoff) -> Self {
223        self.backoff = backoff;
224        self
225    }
226}
227
228impl ReconnectPolicy for BackoffPolicy {
229    /// The first attempt after a drop is immediate; the rest follow the curve.
230    ///
231    /// A failover is often complete in the time it took the client to notice, so
232    /// waiting out a backoff before even trying would add that delay to the
233    /// common case. [`Backoff::delay_for`] takes the 1-based attempt that just
234    /// failed, which is exactly the failure count.
235    fn next_delay(&self, attempt: Attempt<'_>) -> Option<Duration> {
236        if self.max_attempts.is_some_and(|max| attempt.failures >= max) {
237            return None;
238        }
239        if attempt.failures == 0 {
240            return Some(Duration::ZERO);
241        }
242        Some(self.backoff.delay_for(attempt.failures))
243    }
244}
245
246/// The default policy, as [`RabbitMqOptions`](crate::RabbitMqOptions) stores it.
247pub(crate) fn default_policy() -> Arc<dyn ReconnectPolicy> {
248    Arc::new(BackoffPolicy::default())
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    /// The delay a policy gives for the attempt after `failures` failures.
256    fn delay(policy: &dyn ReconnectPolicy, failures: u32) -> Option<Duration> {
257        let error = std::io::Error::other("broker went away");
258        let attempt = if failures == 0 {
259            Attempt::first(Rebuilding::Connection)
260        } else {
261            Attempt::after(Rebuilding::Connection, failures, &error)
262        };
263        policy.next_delay(attempt)
264    }
265
266    #[test]
267    fn the_default_retries_forever_with_a_jittered_exponential_backoff() {
268        let policy = BackoffPolicy::default();
269        assert_eq!(policy.max_attempts, None);
270        let Backoff::Exponential {
271            base,
272            factor,
273            max,
274            jitter,
275        } = policy.backoff
276        else {
277            panic!("the default must be exponential");
278        };
279        assert_eq!(base, DEFAULT_BASE);
280        assert_eq!(factor, 2.0);
281        assert_eq!(max, DEFAULT_MAX);
282        assert!(jitter, "a fleet must not reconnect in lockstep");
283    }
284
285    #[test]
286    fn the_first_attempt_after_a_drop_is_immediate() {
287        assert_eq!(delay(&BackoffPolicy::default(), 0), Some(Duration::ZERO));
288    }
289
290    #[test]
291    fn an_unlimited_policy_always_offers_another_attempt() {
292        let policy = BackoffPolicy::default();
293        for failures in [0, 1, 100, u32::MAX] {
294            assert!(delay(&policy, failures).is_some(), "failures = {failures}");
295        }
296    }
297
298    #[test]
299    fn a_bounded_policy_stops_at_the_limit() {
300        let policy = BackoffPolicy::default().max_attempts(Some(3));
301        // Three attempts are made: after 0, 1 and 2 failures.
302        assert!(delay(&policy, 0).is_some());
303        assert!(delay(&policy, 1).is_some());
304        assert!(delay(&policy, 2).is_some());
305        assert_eq!(delay(&policy, 3), None, "the third failure is the last");
306        assert_eq!(delay(&policy, 4), None);
307    }
308
309    #[test]
310    fn a_zero_attempt_policy_never_reconnects() {
311        let policy = BackoffPolicy::default().max_attempts(Some(0));
312        assert_eq!(delay(&policy, 0), None);
313    }
314
315    #[test]
316    fn the_delay_grows_and_is_capped() {
317        // Without jitter the schedule is exact, so it can be asserted on.
318        let policy = BackoffPolicy::default().backoff(Backoff::Exponential {
319            base: Duration::from_millis(500),
320            factor: 2.0,
321            max: Duration::from_secs(30),
322            jitter: false,
323        });
324        assert_eq!(delay(&policy, 1), Some(Duration::from_millis(500)));
325        assert_eq!(delay(&policy, 2), Some(Duration::from_secs(1)));
326        assert_eq!(delay(&policy, 3), Some(Duration::from_secs(2)));
327        assert_eq!(delay(&policy, 20), Some(Duration::from_secs(30)), "capped");
328    }
329
330    #[test]
331    fn a_jittered_delay_never_exceeds_the_cap() {
332        let policy = BackoffPolicy::default();
333        for failures in 1..40 {
334            assert!(delay(&policy, failures).expect("unlimited") <= DEFAULT_MAX);
335        }
336    }
337
338    /// A policy that could not be expressed by the built-in one: it reads the
339    /// error and what is being rebuilt, not just the failure count.
340    #[derive(Debug)]
341    struct Picky;
342
343    impl ReconnectPolicy for Picky {
344        fn next_delay(&self, attempt: Attempt<'_>) -> Option<Duration> {
345            // A subscription failing on a live connection is usually a queue
346            // that is gone; waiting does not bring it back.
347            if attempt.rebuilding == Rebuilding::Consumer && attempt.failures >= 1 {
348                return None;
349            }
350            let fatal = attempt
351                .error
352                .is_some_and(|error| error.to_string().contains("ACCESS_REFUSED"));
353            if fatal {
354                return None;
355            }
356            Some(Duration::from_secs(1))
357        }
358    }
359
360    #[test]
361    fn a_custom_policy_can_decide_on_the_error_and_the_target() {
362        let refused = std::io::Error::other("ACCESS_REFUSED - login was refused");
363        assert_eq!(
364            Picky.next_delay(Attempt::after(Rebuilding::Connection, 1, &refused)),
365            None,
366            "credentials will not fix themselves"
367        );
368
369        let flaky = std::io::Error::other("connection reset by peer");
370        assert_eq!(
371            Picky.next_delay(Attempt::after(Rebuilding::Connection, 9, &flaky)),
372            Some(Duration::from_secs(1)),
373            "a network blip is retried regardless of the count"
374        );
375        assert_eq!(
376            Picky.next_delay(Attempt::after(Rebuilding::Consumer, 1, &flaky)),
377            None,
378            "but a consumer is given up on"
379        );
380    }
381
382    #[test]
383    fn a_policy_is_usable_behind_the_arc_the_options_store_it_in() {
384        let policy: Arc<dyn ReconnectPolicy> = Arc::new(Picky);
385        assert_eq!(
386            policy.next_delay(Attempt::first(Rebuilding::Connection)),
387            Some(Duration::from_secs(1))
388        );
389        // And `Debug` survives erasure, which is what keeps `RabbitMqOptions`
390        // honest when it prints itself.
391        assert!(format!("{policy:?}").contains("Picky"));
392    }
393}