Skip to main content

rich_ext/
countdown.rs

1//! Retry and rate-limit countdowns: a [`Backoff`] policy, the
2//! [`RetryStatus`] and [`RateLimit`] lines, a shrinking [`CountdownBar`], and
3//! [`CountdownWait`], which waits while showing the time left.
4//!
5//! ```text
6//! ⚠ warning attempt 3/5 failed: connection reset — retrying in 4s
7//! ⚠ warning rate limited — resets in 0:00:42 (0/100 left)
8//! ```
9//!
10//! Every line carries its meaning in words and symbols, so it reads the same
11//! without colour; without colour a bar is drawn as `[####....]`. When motion
12//! is unwelcome ([`Motion::Static`]: a non-interactive target, or reduced
13//! motion or no animation in the [`AccessibilityPolicy`]), a wait prints its
14//! status once instead of redrawing it every tick, so a log gets one line per
15//! attempt.
16//!
17//! Nothing sleeps or reads a clock unless you ask: [`Backoff`] is pure
18//! arithmetic (its jitter comes from a seed you choose), the renderables are
19//! snapshots of the time left, and [`CountdownWait`] takes an injectable
20//! sleeper.
21//!
22//! ```
23//! use std::time::Duration;
24//! use rich::Console;
25//! use rich_ext::countdown::Backoff;
26//!
27//! let backoff = Backoff::new(Duration::from_secs(1)).attempts(5);
28//! let delays: Vec<u64> = backoff.delays().map(|d| d.as_secs()).collect();
29//! assert_eq!(delays, [1, 2, 4, 8]); // four waits between five attempts
30//!
31//! let status = backoff.status(3, "connection reset").unwrap();
32//! let out = Console::builder().width(70).build().render_to_string(&status);
33//! assert_eq!(out, "⚠ warning attempt 3/5 failed: connection reset — retrying in 4s");
34//! ```
35
36use std::io::Write;
37use std::time::Duration;
38
39use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
40
41use crate::a11y::{AccessibilityPolicy, Status, SymbolSet};
42use crate::cancel::CancelToken;
43use crate::format;
44use crate::live::{LiveCoordinator, LiveError};
45use crate::target::RenderTarget;
46use crate::transfer::{bar_segments, effective_symbols, finish_line, keyed_style};
47
48/// The default styles for countdown keys. [`extended_theme`] includes them;
49/// renderers fall back to them when a theme lacks a key.
50///
51/// [`extended_theme`]: crate::theme::extended_theme
52pub const STYLES: &[(&str, &str)] = &[
53    ("countdown.remaining", "bold cyan"),
54    ("countdown.bar", "cyan"),
55    ("countdown.attempt", "bold"),
56    ("countdown.reason", "none"),
57    ("countdown.retry", "yellow"),
58    ("countdown.failed", "bold red"),
59    ("countdown.limited", "yellow"),
60    ("countdown.quota", "dim"),
61];
62
63fn style(console: &Console, key: &str) -> Style {
64    keyed_style(console, STYLES, key)
65}
66
67/// Time left as a countdown reads it: whole seconds rounded up, `4s`, `59s`,
68/// then [`format::duration`] from a minute (`1m 05s`, `2h 00m 00s`).
69pub fn remaining_label(left: Duration) -> String {
70    let secs = left
71        .as_secs()
72        .saturating_add(u64::from(left.subsec_nanos() > 0));
73    if secs < 60 {
74        format!("{secs}s")
75    } else {
76        format::duration(Duration::from_secs(secs))
77    }
78}
79
80fn dash(console: &Console) -> &'static str {
81    if console.ascii_only() { " - " } else { " — " }
82}
83
84/// When to try again: exponential backoff with a cap, an optional attempt
85/// limit and optional deterministic jitter.
86///
87/// The delay after failed attempt `n` (from 1) is
88/// `initial × factorⁿ⁻¹`, capped at `max`, then reduced by up to `jitter` of
89/// itself (so `jitter(0.5, seed)` gives between half and all of it).
90///
91/// ```
92/// use std::time::Duration;
93/// use rich_ext::countdown::Backoff;
94///
95/// let secs = Duration::from_secs;
96/// let backoff = Backoff::new(secs(1)).factor(3.0).max(secs(20));
97/// assert_eq!(backoff.delay(1), Some(secs(1)));
98/// assert_eq!(backoff.delay(3), Some(secs(9)));
99/// assert_eq!(backoff.delay(4), Some(secs(20)));
100///
101/// let jittered = backoff.clone().jitter(0.5, 42);
102/// let d = jittered.delay(3).unwrap();
103/// assert!(d >= secs(9) / 2 && d <= secs(9));
104/// assert_eq!(jittered.delay(3), Some(d)); // same seed, same delays
105/// ```
106#[derive(Clone, Debug, PartialEq)]
107pub struct Backoff {
108    initial: Duration,
109    factor: f64,
110    max: Duration,
111    max_attempts: Option<u32>,
112    jitter: f64,
113    seed: u64,
114}
115
116impl Backoff {
117    /// Start at `initial`, doubling, capped at one minute, attempts unlimited,
118    /// no jitter.
119    pub fn new(initial: Duration) -> Self {
120        Backoff {
121            initial,
122            factor: 2.0,
123            max: Duration::from_secs(60),
124            max_attempts: None,
125            jitter: 0.0,
126            seed: 0,
127        }
128    }
129
130    /// Multiply the delay by `factor` after each failure (at least 1).
131    #[must_use]
132    pub fn factor(mut self, factor: f64) -> Self {
133        self.factor = if factor.is_finite() {
134            factor.max(1.0)
135        } else {
136            1.0
137        };
138        self
139    }
140
141    /// Never wait longer than `max`.
142    #[must_use]
143    pub fn max(mut self, max: Duration) -> Self {
144        self.max = max;
145        self
146    }
147
148    /// Give up after `attempts` attempts in all (at least 1).
149    #[must_use]
150    pub fn attempts(mut self, attempts: u32) -> Self {
151        self.max_attempts = Some(attempts.max(1));
152        self
153    }
154
155    /// Shorten each delay by a pseudo-random part of up to `fraction`
156    /// (0..=1) of it, drawn from `seed`: the same seed always gives the same
157    /// delays, so tests stay deterministic. Seed from the clock or process id
158    /// in production so clients spread out.
159    #[must_use]
160    pub fn jitter(mut self, fraction: f64, seed: u64) -> Self {
161        self.jitter = if fraction.is_finite() {
162            fraction.clamp(0.0, 1.0)
163        } else {
164            0.0
165        };
166        self.seed = seed;
167        self
168    }
169
170    /// The attempt limit, if any.
171    pub fn max_attempts(&self) -> Option<u32> {
172        self.max_attempts
173    }
174
175    /// How long to wait after failed attempt `attempt` (from 1), or `None`
176    /// when that was the last attempt allowed.
177    pub fn delay(&self, attempt: u32) -> Option<Duration> {
178        let attempt = attempt.max(1);
179        if self.max_attempts.is_some_and(|max| attempt >= max) {
180            return None;
181        }
182        // factorⁿ may overflow to infinity; zero times it is still zero.
183        let exponent = i32::try_from(attempt - 1).unwrap_or(i32::MAX);
184        let base = if self.initial.is_zero() {
185            0.0
186        } else {
187            self.initial.as_secs_f64() * self.factor.powi(exponent)
188        };
189        let capped = base.min(self.max.as_secs_f64());
190        let unit = (splitmix64(self.seed ^ u64::from(attempt)) >> 11) as f64 / (1u64 << 53) as f64;
191        // Jitter scales by a factor in (0, 1], so it never lengthens the
192        // delay; a value too big for a `Duration` is the cap itself.
193        let delay = capped * (1.0 - self.jitter * unit);
194        let delay = Duration::try_from_secs_f64(delay).unwrap_or(self.max);
195        Some(delay.min(self.max))
196    }
197
198    /// The delays between attempts, in order; endless without an attempt
199    /// limit.
200    pub fn delays(&self) -> impl Iterator<Item = Duration> + '_ {
201        (1..).map_while(move |attempt| self.delay(attempt))
202    }
203
204    /// The status line for failed attempt `attempt`: retrying in its delay,
205    /// or giving up after the last attempt. `None` only for attempt 0.
206    pub fn status(&self, attempt: u32, reason: impl Into<String>) -> Option<RetryStatus> {
207        if attempt == 0 {
208            return None;
209        }
210        let mut status = RetryStatus::new(attempt).reason(reason);
211        if let Some(max) = self.max_attempts {
212            status = status.max_attempts(max);
213        }
214        if let Some(delay) = self.delay(attempt) {
215            status = status.retrying_in(delay);
216        }
217        Some(status)
218    }
219}
220
221/// SplitMix64: a small, well-mixed hash for the jitter.
222fn splitmix64(seed: u64) -> u64 {
223    let mut z = seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
224    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
225    z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
226    z ^ (z >> 31)
227}
228
229/// Append a countdown bar (without its time label, which the sentence
230/// already has) to `line`, shrunk to the room left, or not at all when fewer
231/// than 5 cells remain.
232fn append_bar(
233    line: &mut Vec<Segment>,
234    console: &Console,
235    options: &ConsoleOptions,
236    total: Duration,
237    left: Duration,
238    width: usize,
239) {
240    let used: usize = line.iter().map(Segment::cell_length).sum();
241    let width = width.min(options.max_width.saturating_sub(used + 2));
242    if width < 5 {
243        return;
244    }
245    line.push(Segment::new("  ", None));
246    let mut bar = CountdownBar::new(total, left)
247        .width(width)
248        .segments(console, options);
249    bar.truncate(bar.len().saturating_sub(2));
250    line.extend(bar);
251}
252
253/// A bar that shrinks as time runs out, followed by the time left.
254///
255/// ```
256/// use std::time::Duration;
257/// use rich::Console;
258/// use rich_ext::countdown::CountdownBar;
259///
260/// let bar = CountdownBar::new(Duration::from_secs(10), Duration::from_secs(4)).width(12);
261/// let out = Console::builder().width(40).build().render_to_string(&bar);
262/// assert_eq!(out, "[####......]  4s");
263/// ```
264#[derive(Clone, Debug, PartialEq, Eq)]
265pub struct CountdownBar {
266    total: Duration,
267    remaining: Duration,
268    width: usize,
269}
270
271impl CountdownBar {
272    /// A countdown of `total` with `remaining` left, 20 cells wide.
273    pub fn new(total: Duration, remaining: Duration) -> Self {
274        CountdownBar {
275            total,
276            remaining: remaining.min(total),
277            width: 20,
278        }
279    }
280
281    /// The bar's width in cells (at least 3).
282    #[must_use]
283    pub fn width(mut self, width: usize) -> Self {
284        self.width = width.max(3);
285        self
286    }
287
288    fn ratio(&self) -> f64 {
289        if self.total.is_zero() {
290            0.0
291        } else {
292            self.remaining.as_secs_f64() / self.total.as_secs_f64()
293        }
294    }
295
296    fn segments(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
297        let mut out = bar_segments(
298            console,
299            options,
300            self.width.min(options.max_width),
301            Some(self.ratio()),
302            0.0,
303            Some(style(console, "countdown.bar")),
304        );
305        out.push(Segment::new("  ", None));
306        out.push(Segment::new(
307            remaining_label(self.remaining),
308            Some(style(console, "countdown.remaining")),
309        ));
310        out
311    }
312}
313
314impl Renderable for CountdownBar {
315    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
316        finish_line(self.segments(console, options), options.max_width)
317    }
318}
319
320/// One failed attempt: which, why, and when the next one comes (or that
321/// there will be none). See the [module docs](self).
322#[derive(Clone, Debug, PartialEq, Eq)]
323pub struct RetryStatus {
324    attempt: u32,
325    max_attempts: Option<u32>,
326    reason: Option<String>,
327    remaining: Option<Duration>,
328    total: Option<Duration>,
329    bar_width: usize,
330    symbols: SymbolSet,
331}
332
333impl RetryStatus {
334    /// Attempt `attempt` (from 1) failed; no retry scheduled yet, so it reads
335    /// as giving up until [`retrying_in`](Self::retrying_in) is set.
336    pub fn new(attempt: u32) -> Self {
337        RetryStatus {
338            attempt,
339            max_attempts: None,
340            reason: None,
341            remaining: None,
342            total: None,
343            bar_width: 20,
344            symbols: SymbolSet::Unicode,
345        }
346    }
347
348    /// Show the attempt as `n/max`.
349    #[must_use]
350    pub fn max_attempts(mut self, max: u32) -> Self {
351        self.max_attempts = Some(max);
352        self
353    }
354
355    /// Why the attempt failed.
356    #[must_use]
357    pub fn reason(mut self, reason: impl Into<String>) -> Self {
358        self.reason = Some(reason.into());
359        self
360    }
361
362    /// The next attempt comes after `left`.
363    #[must_use]
364    pub fn retrying_in(mut self, left: Duration) -> Self {
365        self.remaining = Some(left);
366        self
367    }
368
369    /// Also draw a [`CountdownBar`] of the whole `delay` (use with
370    /// [`retrying_in`](Self::retrying_in) for the time left).
371    #[must_use]
372    pub fn bar(mut self, delay: Duration) -> Self {
373        self.total = Some(delay);
374        self
375    }
376
377    /// The bar's width in cells (default 20).
378    #[must_use]
379    pub fn bar_width(mut self, width: usize) -> Self {
380        self.bar_width = width;
381        self
382    }
383
384    /// How the status is marked (default Unicode; ASCII on an ASCII-only
385    /// console).
386    #[must_use]
387    pub fn symbols(mut self, set: SymbolSet) -> Self {
388        self.symbols = set;
389        self
390    }
391
392    /// Whether this is the final failure (no retry scheduled).
393    pub fn giving_up(&self) -> bool {
394        self.remaining.is_none()
395    }
396
397    /// The same status with `left` remaining: the frame for one tick.
398    #[must_use]
399    pub fn at(&self, left: Duration) -> Self {
400        let mut next = self.clone();
401        next.remaining = Some(left);
402        next
403    }
404
405    /// Warning while retrying, error when giving up.
406    pub fn status(&self) -> Status {
407        if self.giving_up() {
408            Status::Error
409        } else {
410            Status::Warning
411        }
412    }
413}
414
415impl Renderable for RetryStatus {
416    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
417        let set = effective_symbols(console, self.symbols);
418        let (marker_key, attempt) = match self.max_attempts {
419            Some(max) => (self.status(), format!("attempt {}/{max}", self.attempt)),
420            None => (self.status(), format!("attempt {}", self.attempt)),
421        };
422        let key = if marker_key == Status::Error {
423            "countdown.failed"
424        } else {
425            "countdown.retry"
426        };
427        let mut line = vec![
428            Segment::new(marker_key.symbol(set), Some(style(console, key))),
429            Segment::new(" ", None),
430            Segment::new(attempt, Some(style(console, "countdown.attempt"))),
431            Segment::new(" failed", None),
432        ];
433        if let Some(reason) = &self.reason {
434            line.push(Segment::new(": ", None));
435            line.push(Segment::new(
436                reason.clone(),
437                Some(style(console, "countdown.reason")),
438            ));
439        }
440        line.push(Segment::new(dash(console), None));
441        match self.remaining {
442            Some(left) => {
443                line.push(Segment::new("retrying in ", None));
444                line.push(Segment::new(
445                    remaining_label(left),
446                    Some(style(console, "countdown.remaining")),
447                ));
448                if let Some(total) = self.total {
449                    append_bar(&mut line, console, options, total, left, self.bar_width);
450                }
451            }
452            None => line.push(Segment::new("giving up", Some(style(console, key)))),
453        }
454        finish_line(line, options.max_width)
455    }
456}
457
458/// A rate limit: when it resets and, if known, how much of the quota is left.
459///
460/// ```
461/// use std::time::Duration;
462/// use rich::Console;
463/// use rich_ext::countdown::RateLimit;
464///
465/// let limit = RateLimit::new(Duration::from_secs(42)).limit(100).remaining(0).scope("search API");
466/// let out = Console::builder().width(80).build().render_to_string(&limit);
467/// assert_eq!(out, "⚠ warning rate limited (search API) — resets in 0:00:42 (0/100 left)");
468/// ```
469#[derive(Clone, Debug, PartialEq, Eq)]
470pub struct RateLimit {
471    resets_in: Duration,
472    limit: Option<u64>,
473    remaining: Option<u64>,
474    scope: Option<String>,
475    window: Option<Duration>,
476    bar_width: usize,
477    symbols: SymbolSet,
478}
479
480impl RateLimit {
481    /// Limited until `resets_in` from now.
482    pub fn new(resets_in: Duration) -> Self {
483        RateLimit {
484            resets_in,
485            limit: None,
486            remaining: None,
487            scope: None,
488            window: None,
489            bar_width: 20,
490            symbols: SymbolSet::Unicode,
491        }
492    }
493
494    /// The quota per window.
495    #[must_use]
496    pub fn limit(mut self, limit: u64) -> Self {
497        self.limit = Some(limit);
498        self
499    }
500
501    /// How much of the quota is left.
502    #[must_use]
503    pub fn remaining(mut self, remaining: u64) -> Self {
504        self.remaining = Some(remaining);
505        self
506    }
507
508    /// What is limited, e.g. an API name.
509    #[must_use]
510    pub fn scope(mut self, scope: impl Into<String>) -> Self {
511        self.scope = Some(scope.into());
512        self
513    }
514
515    /// Also draw a [`CountdownBar`] over a reset window of `window`.
516    #[must_use]
517    pub fn bar(mut self, window: Duration) -> Self {
518        self.window = Some(window);
519        self
520    }
521
522    /// The bar's width in cells (default 20).
523    #[must_use]
524    pub fn bar_width(mut self, width: usize) -> Self {
525        self.bar_width = width;
526        self
527    }
528
529    /// How the status is marked (default Unicode; ASCII on an ASCII-only
530    /// console).
531    #[must_use]
532    pub fn symbols(mut self, set: SymbolSet) -> Self {
533        self.symbols = set;
534        self
535    }
536
537    /// The same limit with `left` until the reset: the frame for one tick.
538    #[must_use]
539    pub fn at(&self, left: Duration) -> Self {
540        let mut next = self.clone();
541        next.resets_in = left;
542        next
543    }
544}
545
546impl Renderable for RateLimit {
547    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
548        let set = effective_symbols(console, self.symbols);
549        let limited = style(console, "countdown.limited");
550        let mut line = vec![
551            Segment::new(Status::Warning.symbol(set), Some(limited.clone())),
552            Segment::new(" ", None),
553            Segment::new("rate limited", Some(limited)),
554        ];
555        if let Some(scope) = &self.scope {
556            line.push(Segment::new(format!(" ({scope})"), None));
557        }
558        line.push(Segment::new(dash(console), None));
559        line.push(Segment::new("resets in ", None));
560        line.push(Segment::new(
561            format::clock(self.resets_in),
562            Some(style(console, "countdown.remaining")),
563        ));
564        let quota = match (self.remaining, self.limit) {
565            (Some(left), Some(limit)) => Some(format!("{left}/{limit} left")),
566            (Some(left), None) => Some(format!("{left} left")),
567            (None, Some(limit)) => Some(format!("limit {limit}")),
568            (None, None) => None,
569        };
570        if let Some(quota) = quota {
571            line.push(Segment::new(
572                format!(" ({quota})"),
573                Some(style(console, "countdown.quota")),
574            ));
575        }
576        if let Some(window) = self.window {
577            append_bar(
578                &mut line,
579                console,
580                options,
581                window,
582                self.resets_in,
583                self.bar_width,
584            );
585        }
586        finish_line(line, options.max_width)
587    }
588}
589
590/// Whether a countdown redraws itself or is printed once.
591#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
592pub enum Motion {
593    /// Redraw every tick in a live region.
594    #[default]
595    Animated,
596    /// Print once; the wait is silent. For logs, pipes and reduced motion.
597    Static,
598}
599
600impl Motion {
601    /// [`Static`](Motion::Static) for a non-interactive target or a policy
602    /// asking for reduced motion or no animation; otherwise animated.
603    pub fn for_target(target: &RenderTarget, policy: &AccessibilityPolicy) -> Motion {
604        use rich::protocol::RenderEnvironment;
605        if !target.capabilities().interactive || policy.no_animation || policy.reduced_motion {
606            Motion::Static
607        } else {
608            Motion::Animated
609        }
610    }
611}
612
613/// How a [`CountdownWait`] ended.
614#[derive(Clone, Copy, Debug, PartialEq, Eq)]
615pub enum WaitOutcome {
616    /// The whole time passed.
617    Elapsed,
618    /// The token was cancelled first.
619    Cancelled,
620}
621
622/// A blocking wait that reports the time left every tick and stops early
623/// when a [`CancelToken`] is cancelled.
624///
625/// Time is counted from the sleeps themselves, so an injected sleeper makes
626/// the wait instant and deterministic:
627///
628/// ```
629/// use std::time::Duration;
630/// use rich_ext::countdown::{CountdownWait, WaitOutcome};
631///
632/// let mut ticks = Vec::new();
633/// let outcome = CountdownWait::new(Duration::from_secs(2))
634///     .tick(Duration::from_secs(1))
635///     .sleeper(|_| {})
636///     .run(|left| ticks.push(left.as_secs()));
637/// assert_eq!(outcome, WaitOutcome::Elapsed);
638/// assert_eq!(ticks, [2, 1, 0]);
639/// ```
640pub struct CountdownWait<'a> {
641    total: Duration,
642    tick: Duration,
643    cancel: Option<CancelToken>,
644    sleep: Box<dyn FnMut(Duration) + 'a>,
645}
646
647impl<'a> CountdownWait<'a> {
648    /// Wait `total`, ticking every 250 ms with `std::thread::sleep`.
649    pub fn new(total: Duration) -> Self {
650        CountdownWait {
651            total,
652            tick: Duration::from_millis(250),
653            cancel: None,
654            sleep: Box::new(std::thread::sleep),
655        }
656    }
657
658    /// Report (and check for cancellation) every `tick` (at least 1 ms).
659    #[must_use]
660    pub fn tick(mut self, tick: Duration) -> Self {
661        self.tick = tick.max(Duration::from_millis(1));
662        self
663    }
664
665    /// Stop early once `token` is cancelled.
666    #[must_use]
667    pub fn cancel(mut self, token: CancelToken) -> Self {
668        self.cancel = Some(token);
669        self
670    }
671
672    /// Sleep with `sleep` instead of `std::thread::sleep`.
673    #[must_use]
674    pub fn sleeper(mut self, sleep: impl FnMut(Duration) + 'a) -> Self {
675        self.sleep = Box::new(sleep);
676        self
677    }
678
679    /// Wait, calling `on_tick` with the time left before each sleep and once
680    /// with zero at the end.
681    pub fn run(mut self, mut on_tick: impl FnMut(Duration)) -> WaitOutcome {
682        let mut left = self.total;
683        loop {
684            if self.cancel.as_ref().is_some_and(CancelToken::is_cancelled) {
685                return WaitOutcome::Cancelled;
686            }
687            on_tick(left);
688            if left.is_zero() {
689                return WaitOutcome::Elapsed;
690            }
691            let step = self.tick.min(left);
692            (self.sleep)(step);
693            left -= step;
694        }
695    }
696
697    /// Wait while showing `frame(time_left)` through `live`.
698    ///
699    /// [`Motion::Animated`] draws the frame in a new region, updates it every
700    /// tick and removes it at the end. [`Motion::Static`] prints the first
701    /// frame once as an ordinary line, which a log keeps.
702    pub fn run_live<W: Write, R: Renderable>(
703        self,
704        live: &mut LiveCoordinator<W>,
705        target: &RenderTarget,
706        motion: Motion,
707        frame: impl Fn(Duration) -> R,
708    ) -> Result<WaitOutcome, LiveError> {
709        if motion == Motion::Static {
710            live.print(&target.segments(&frame(self.total)))?;
711            return Ok(self.run(|_| {}));
712        }
713        let region = live.add(target.segments(&frame(self.total)))?;
714        let mut error = None;
715        let outcome = self.run(|left| {
716            if error.is_some() {
717                return;
718            }
719            let result = live
720                .update(region.clone(), target.segments(&frame(left)))
721                .and_then(|()| live.refresh());
722            if let Err(e) = result {
723                error = Some(e);
724            }
725        });
726        if let Some(e) = error {
727            return Err(e);
728        }
729        live.remove(region)?;
730        live.refresh()?;
731        Ok(outcome)
732    }
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738
739    #[test]
740    fn labels_round_up_to_whole_seconds() {
741        assert_eq!(remaining_label(Duration::ZERO), "0s");
742        assert_eq!(remaining_label(Duration::from_millis(3100)), "4s");
743        assert_eq!(remaining_label(Duration::from_secs(65)), "1m 05s");
744    }
745
746    #[test]
747    fn jitter_stays_in_range_and_varies_by_attempt() {
748        let backoff = Backoff::new(Duration::from_secs(8))
749            .factor(1.0)
750            .jitter(1.0, 7);
751        let delays: Vec<Duration> = (1..=5).filter_map(|a| backoff.delay(a)).collect();
752        assert!(delays.iter().all(|d| *d <= Duration::from_secs(8)));
753        assert!(delays.windows(2).any(|w| w[0] != w[1]));
754    }
755}