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}