Skip to main content

retry_error/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![doc = include_str!("../README.md")]
3// @@ begin lint list maintained by maint/add_warning @@
4#![allow(renamed_and_removed_lints)] // @@REMOVE_WHEN(ci_arti_stable)
5#![allow(unknown_lints)] // @@REMOVE_WHEN(ci_arti_nightly)
6#![warn(missing_docs)]
7#![warn(noop_method_call)]
8#![warn(unreachable_pub)]
9#![warn(clippy::all)]
10#![deny(clippy::await_holding_lock)]
11#![deny(clippy::cargo_common_metadata)]
12#![deny(clippy::cast_lossless)]
13#![deny(clippy::checked_conversions)]
14#![warn(clippy::cognitive_complexity)]
15#![deny(clippy::debug_assert_with_mut_call)]
16#![deny(clippy::exhaustive_enums)]
17#![deny(clippy::exhaustive_structs)]
18#![deny(clippy::expl_impl_clone_on_copy)]
19#![deny(clippy::fallible_impl_from)]
20#![deny(clippy::implicit_clone)]
21#![deny(clippy::large_stack_arrays)]
22#![warn(clippy::manual_ok_or)]
23#![deny(clippy::missing_docs_in_private_items)]
24#![warn(clippy::needless_borrow)]
25#![warn(clippy::needless_pass_by_value)]
26#![warn(clippy::option_option)]
27#![deny(clippy::print_stderr)]
28#![deny(clippy::print_stdout)]
29#![warn(clippy::rc_buffer)]
30#![deny(clippy::ref_option_ref)]
31#![warn(clippy::semicolon_if_nothing_returned)]
32#![warn(clippy::trait_duplication_in_bounds)]
33#![deny(clippy::unchecked_time_subtraction)]
34#![deny(clippy::unnecessary_wraps)]
35#![warn(clippy::unseparated_literal_suffix)]
36#![deny(clippy::unwrap_used)]
37#![deny(clippy::mod_module_files)]
38#![allow(clippy::let_unit_value)] // This can reasonably be done for explicitness
39#![allow(clippy::uninlined_format_args)]
40#![allow(clippy::significant_drop_in_scrutinee)] // arti/-/merge_requests/588/#note_2812945
41#![allow(clippy::result_large_err)] // temporary workaround for arti#587
42#![allow(clippy::needless_raw_string_hashes)] // complained-about code is fine, often best
43#![allow(clippy::needless_lifetimes)] // See arti#1765
44#![allow(mismatched_lifetime_syntaxes)] // temporary workaround for arti#2060
45#![allow(clippy::collapsible_if)] // See arti#2342
46#![deny(clippy::unused_async)]
47//! <!-- @@ end lint list maintained by maint/add_warning @@ -->
48
49use std::error::Error;
50use std::fmt::{self, Debug, Display, Error as FmtError, Formatter};
51use std::iter;
52use std::time::{Duration, Instant, SystemTime};
53
54/// An error type for use when we're going to do something a few times,
55/// and they might all fail.
56///
57/// To use this error type, initialize a new RetryError before you
58/// start trying to do whatever it is.  Then, every time the operation
59/// fails, use [`RetryError::push()`] to add a new error to the list
60/// of errors.  If the operation fails too many times, you can use
61/// RetryError as an [`Error`] itself.
62///
63/// This type now tracks timestamps for each error occurrence, allowing
64/// users to see when errors occurred and how long the retry process took.
65#[derive(Debug, Clone)]
66pub struct RetryError<E> {
67    /// The operation we were trying to do.
68    doing: String,
69    /// The errors that we encountered when doing the operation.
70    errors: Vec<(Attempt, E, Instant)>,
71    /// The total number of errors we encountered.
72    ///
73    /// This can differ from errors.len() if the errors have been
74    /// deduplicated.
75    n_errors: usize,
76    /// The wall-clock time when the first error occurred.
77    ///
78    /// This is used for human-readable display of absolute timestamps.
79    ///
80    /// We store both types because they serve different purposes:
81    /// - `Instant` (in the errors vec): Monotonic clock for reliable duration calculations.
82    ///   Immune to clock adjustments, but can't be displayed as wall-clock time.
83    /// - `SystemTime` (here): Wall-clock time for displaying when the first error occurred
84    ///   in a human-readable format (e.g., "2025-12-09T10:24:02Z").
85    ///
86    /// We only store `SystemTime` for the first error to show users *when* the problem
87    /// started. Subsequent errors are displayed relative to the first ("+2m 30s"),
88    /// using the reliable `Instant` timestamps.
89    first_error_at: Option<SystemTime>,
90}
91
92/// Represents which attempts, in sequence, failed to complete.
93#[derive(Debug, Clone)]
94enum Attempt {
95    /// A single attempt that failed.
96    Single(usize),
97    /// A range of consecutive attempts that failed.
98    Range(usize, usize),
99}
100
101// TODO: Should we declare that some error is the 'source' of this one?
102// If so, should it be the first failure?  The last?
103impl<E: Debug + AsRef<dyn Error>> Error for RetryError<E> {}
104
105impl<E> RetryError<E> {
106    /// Create a new RetryError, with no failed attempts.
107    ///
108    /// The provided `doing` argument is a short string that describes
109    /// what we were trying to do when we failed too many times.  It
110    /// will be used to format the final error message; it should be a
111    /// phrase that can go after "while trying to".
112    ///
113    /// This RetryError should not be used as-is, since when no
114    /// [`Error`]s have been pushed into it, it doesn't represent an
115    /// actual failure.
116    pub fn in_attempt_to<T: Into<String>>(doing: T) -> Self {
117        RetryError {
118            doing: doing.into(),
119            errors: Vec::new(),
120            n_errors: 0,
121            first_error_at: None,
122        }
123    }
124    /// Add an error to this RetryError with explicit timestamps.
125    ///
126    /// You should call this method when an attempt at the underlying operation
127    /// has failed.
128    ///
129    /// The `instant` parameter should be the monotonic time when the error
130    /// occurred, typically obtained from a runtime's `now()` method.
131    ///
132    /// The `wall_clock` parameter is the wall-clock time when the error occurred,
133    /// used for human-readable display. Pass `None` to skip wall-clock tracking,
134    /// or `Some(SystemTime::now())` for the current time.
135    ///
136    /// # Example
137    /// ```
138    /// # use retry_error::RetryError;
139    /// # use std::time::{Instant, SystemTime};
140    /// let mut retry_err: RetryError<&str> = RetryError::in_attempt_to("connect");
141    /// let now = Instant::now();
142    /// retry_err.push_timed("connection failed", now, Some(SystemTime::now()));
143    /// ```
144    pub fn push_timed<T>(&mut self, err: T, instant: Instant, wall_clock: Option<SystemTime>)
145    where
146        T: Into<E>,
147    {
148        if self.n_errors < usize::MAX {
149            self.n_errors += 1;
150            let attempt = Attempt::Single(self.n_errors);
151
152            if self.first_error_at.is_none() {
153                self.first_error_at = wall_clock;
154            }
155
156            self.errors.push((attempt, err.into(), instant));
157        }
158    }
159
160    /// Add an error to this RetryError using the current time.
161    ///
162    /// You should call this method when an attempt at the underlying operation
163    /// has failed.
164    ///
165    /// This is a convenience wrapper around [`push_timed()`](Self::push_timed)
166    /// that uses `Instant::now()` and `SystemTime::now()` for the timestamps.
167    /// For code that needs mockable time (such as in tests), prefer `push_timed()`.
168    pub fn push<T>(&mut self, err: T)
169    where
170        T: Into<E>,
171    {
172        self.push_timed(err, Instant::now(), Some(SystemTime::now()));
173    }
174
175    /// Return an iterator over all of the reasons that the attempt
176    /// behind this RetryError has failed.
177    pub fn sources(&self) -> impl Iterator<Item = &E> {
178        self.errors.iter().map(|(.., e, _)| e)
179    }
180
181    /// Return the number of underlying errors.
182    pub fn len(&self) -> usize {
183        self.errors.len()
184    }
185
186    /// Return true if no underlying errors have been added.
187    pub fn is_empty(&self) -> bool {
188        self.errors.is_empty()
189    }
190
191    /// Add multiple errors to this RetryError using the current time.
192    ///
193    /// This method uses [`push()`](Self::push) internally, which captures
194    /// `SystemTime::now()`. For code that needs mockable time (such as in tests),
195    /// iterate manually and call [`push_timed()`](Self::push_timed) instead.
196    ///
197    /// # Example
198    /// ```
199    /// # use retry_error::RetryError;
200    /// let mut err: RetryError<anyhow::Error> = RetryError::in_attempt_to("parse");
201    /// let errors = vec!["error1", "error2"].into_iter().map(anyhow::Error::msg);
202    /// err.extend(errors);
203    /// ```
204    #[allow(clippy::disallowed_methods)] // This method intentionally uses push()
205    pub fn extend<T>(&mut self, iter: impl IntoIterator<Item = T>)
206    where
207        T: Into<E>,
208    {
209        for item in iter {
210            self.push(item);
211        }
212    }
213
214    /// Group up consecutive errors of the same kind, for easier display.
215    ///
216    /// Two errors have "the same kind" if they return `true` when passed
217    /// to the provided `dedup` function.
218    pub fn dedup_by<F>(&mut self, same_err: F)
219    where
220        F: Fn(&E, &E) -> bool,
221    {
222        let mut old_errs = Vec::new();
223        std::mem::swap(&mut old_errs, &mut self.errors);
224
225        for (attempt, err, timestamp) in old_errs {
226            if let Some((last_attempt, last_err, ..)) = self.errors.last_mut() {
227                if same_err(last_err, &err) {
228                    last_attempt.grow();
229                } else {
230                    self.errors.push((attempt, err, timestamp));
231                }
232            } else {
233                self.errors.push((attempt, err, timestamp));
234            }
235        }
236    }
237
238    /// Add multiple errors to this RetryError, preserving their original timestamps.
239    ///
240    /// The errors from other will be added to this RetryError, with their original
241    /// timestamps retained. The `Attempt` counters will be updated to continue from
242    /// the current state of this RetryError. `Attempt::Range` entries are preserved as ranges
243    pub fn extend_from_retry_error(&mut self, other: RetryError<E>) {
244        if self.first_error_at.is_none() {
245            self.first_error_at = other.first_error_at;
246        }
247
248        for (attempt, err, timestamp) in other.errors {
249            let new_attempt = match attempt {
250                Attempt::Single(_) => {
251                    let Some(new_n_errors) = self.n_errors.checked_add(1) else {
252                        break;
253                    };
254                    self.n_errors = new_n_errors;
255                    Attempt::Single(new_n_errors)
256                }
257                Attempt::Range(first, last) => {
258                    let count = last - first + 1;
259                    let Some(new_n_errors) = self.n_errors.checked_add(count) else {
260                        break;
261                    };
262                    let start = self.n_errors + 1;
263                    self.n_errors = new_n_errors;
264                    Attempt::Range(start, new_n_errors)
265                }
266            };
267
268            self.errors.push((new_attempt, err, timestamp));
269        }
270    }
271}
272
273impl<E: PartialEq<E>> RetryError<E> {
274    /// Group up consecutive errors of the same kind, according to the
275    /// `PartialEq` implementation.
276    pub fn dedup(&mut self) {
277        self.dedup_by(PartialEq::eq);
278    }
279}
280
281impl Attempt {
282    /// Extend this attempt by a single additional failure.
283    fn grow(&mut self) {
284        *self = match *self {
285            Attempt::Single(idx) => Attempt::Range(idx, idx + 1),
286            Attempt::Range(first, last) => Attempt::Range(first, last + 1),
287        };
288    }
289}
290
291impl<E> IntoIterator for RetryError<E> {
292    type Item = E;
293    type IntoIter = std::vec::IntoIter<E>;
294    #[allow(clippy::needless_collect)]
295    // TODO We have to use collect/into_iter here for now, since
296    // the actual Map<> type can't be named.  Once Rust lets us say
297    // `type IntoIter = impl Iterator<Item=E>` then we fix the code
298    // and turn the Clippy warning back on.
299    fn into_iter(self) -> Self::IntoIter {
300        self.errors
301            .into_iter()
302            .map(|(.., e, _)| e)
303            .collect::<Vec<_>>()
304            .into_iter()
305    }
306}
307
308impl Display for Attempt {
309    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> {
310        match self {
311            Attempt::Single(idx) => write!(f, "Attempt {}", idx),
312            Attempt::Range(first, last) => write!(f, "Attempts {}..{}", first, last),
313        }
314    }
315}
316
317impl<E: AsRef<dyn Error>> Display for RetryError<E> {
318    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> {
319        let show_timestamps = f.alternate();
320
321        match self.n_errors {
322            0 => write!(f, "Unable to {}. (No errors given)", self.doing),
323            1 => {
324                write!(f, "Unable to {}", self.doing)?;
325
326                if show_timestamps {
327                    if let (Some((.., timestamp)), Some(first_at)) =
328                        (self.errors.first(), self.first_error_at)
329                    {
330                        write!(
331                            f,
332                            " at {} ({})",
333                            humantime::format_rfc3339(first_at),
334                            FormatTimeAgo(timestamp.elapsed())
335                        )?;
336                    }
337                }
338
339                write!(f, ": ")?;
340                fmt_error_with_sources(self.errors[0].1.as_ref(), f)
341            }
342            n => {
343                write!(
344                    f,
345                    "Tried to {} {} times, but all attempts failed",
346                    self.doing, n
347                )?;
348
349                if show_timestamps {
350                    if let (Some(first_at), Some((.., first_ts)), Some((.., last_ts))) =
351                        (self.first_error_at, self.errors.first(), self.errors.last())
352                    {
353                        let duration = last_ts.saturating_duration_since(*first_ts);
354
355                        write!(f, " (from {} ", humantime::format_rfc3339(first_at))?;
356
357                        if duration.as_secs() > 0 {
358                            write!(f, "to {}", humantime::format_rfc3339(first_at + duration))?;
359                        }
360
361                        write!(f, ", {})", FormatTimeAgo(last_ts.elapsed()))?;
362                    }
363                }
364
365                let first_ts = self.errors.first().map(|(.., ts)| ts);
366                for (attempt, e, timestamp) in &self.errors {
367                    write!(f, "\n{}", attempt)?;
368
369                    if show_timestamps {
370                        if let Some(first_ts) = first_ts {
371                            let offset = timestamp.saturating_duration_since(*first_ts);
372                            if offset.as_secs() > 0 {
373                                write!(f, " (+{})", FormatDuration(offset))?;
374                            }
375                        }
376                    }
377
378                    write!(f, ": ")?;
379                    fmt_error_with_sources(e.as_ref(), f)?;
380                }
381                Ok(())
382            }
383        }
384    }
385}
386
387/// A wrapper for formatting a [`Duration`] in a human-readable way.
388/// Produces output like "2m 30s", "5h 12m", "45s", "500ms".
389///
390/// We use this instead of `humantime::format_duration` because humantime tends to produce overly verbose output.
391struct FormatDuration(Duration);
392
393impl Display for FormatDuration {
394    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
395        fmt_duration_impl(self.0, f)
396    }
397}
398
399/// A wrapper for formatting a [`Duration`] with "ago" suffix.
400struct FormatTimeAgo(Duration);
401
402impl Display for FormatTimeAgo {
403    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
404        let secs = self.0.as_secs();
405        let millis = self.0.as_millis();
406
407        // Special case: very recent times show as "just now" rather than "0s ago" or "0ms ago"
408        if secs == 0 && millis == 0 {
409            return write!(f, "just now");
410        }
411
412        fmt_duration_impl(self.0, f)?;
413        write!(f, " ago")
414    }
415}
416
417/// Internal helper to format a duration.
418///
419/// This function contains the actual formatting logic to avoid duplication
420/// between `FormatDuration` and `FormatTimeAgo`.
421fn fmt_duration_impl(duration: Duration, f: &mut Formatter<'_>) -> fmt::Result {
422    let secs = duration.as_secs();
423
424    if secs == 0 {
425        let millis = duration.as_millis();
426        if millis == 0 {
427            write!(f, "0s")
428        } else {
429            write!(f, "{}ms", millis)
430        }
431    } else if secs < 60 {
432        write!(f, "{}s", secs)
433    } else if secs < 3600 {
434        let mins = secs / 60;
435        let rem_secs = secs % 60;
436        if rem_secs == 0 {
437            write!(f, "{}m", mins)
438        } else {
439            write!(f, "{}m {}s", mins, rem_secs)
440        }
441    } else {
442        let hours = secs / 3600;
443        let mins = (secs % 3600) / 60;
444        if mins == 0 {
445            write!(f, "{}h", hours)
446        } else {
447            write!(f, "{}h {}m", hours, mins)
448        }
449    }
450}
451
452/// Helper: formats a [`std::error::Error`] and its sources (as `"error: source"`)
453///
454/// Avoids duplication in messages by not printing messages which are
455/// wholly-contained (textually) within already-printed messages.
456///
457/// Offered as a `fmt` function:
458/// this is for use in more-convenient higher-level error handling functionality,
459/// rather than directly in application/functional code.
460///
461/// This is used by `RetryError`'s impl of `Display`,
462/// but will be useful for other error-handling situations.
463///
464/// # Example
465///
466/// ```
467/// use std::fmt::{self, Display};
468///
469/// #[derive(Debug, thiserror::Error)]
470/// #[error("some pernickety problem")]
471/// struct Pernickety;
472///
473/// #[derive(Debug, thiserror::Error)]
474/// enum ApplicationError {
475///     #[error("everything is terrible")]
476///     Terrible(#[source] Pernickety),
477/// }
478///
479/// struct Wrapper(Box<dyn std::error::Error>);
480/// impl Display for Wrapper {
481///     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
482///         retry_error::fmt_error_with_sources(&*self.0, f)
483///     }
484/// }
485///
486/// let bad = Pernickety;
487/// let err = ApplicationError::Terrible(bad);
488///
489/// let printed = Wrapper(err.into()).to_string();
490/// assert_eq!(printed, "everything is terrible: some pernickety problem");
491/// ```
492pub fn fmt_error_with_sources(mut e: &dyn Error, f: &mut fmt::Formatter) -> fmt::Result {
493    // We deduplicate the errors here under the assumption that the `Error` trait is poorly defined
494    // and contradictory, and that some error types will duplicate error messages. This is
495    // controversial, and since there isn't necessarily agreement, we should stick with the status
496    // quo here and avoid changing this behaviour without further discussion.
497    let mut last = String::new();
498    let mut sep = iter::once("").chain(iter::repeat(": "));
499
500    // Note that this loop does not use tor_basic_utils::ErrorSources.  We can't, because `e` is not
501    // `Error + 'static`.  But we shouldn't use ErrorSources here, since io::Error will format
502    // its inner by_ref() error, and so it's desirable that `source` skips over it.
503    loop {
504        let this = e.to_string();
505        if !last.contains(&this) {
506            write!(f, "{}{}", sep.next().expect("repeat ended"), &this)?;
507        }
508        last = this;
509
510        if let Some(ne) = e.source() {
511            e = ne;
512        } else {
513            break;
514        }
515    }
516    Ok(())
517}
518
519#[cfg(test)]
520mod test {
521    // @@ begin test lint list maintained by maint/add_warning @@
522    #![allow(clippy::bool_assert_comparison)]
523    #![allow(clippy::clone_on_copy)]
524    #![allow(clippy::dbg_macro)]
525    #![allow(clippy::mixed_attributes_style)]
526    #![allow(clippy::print_stderr)]
527    #![allow(clippy::print_stdout)]
528    #![allow(clippy::single_char_pattern)]
529    #![allow(clippy::unwrap_used)]
530    #![allow(clippy::unchecked_time_subtraction)]
531    #![allow(clippy::useless_vec)]
532    #![allow(clippy::needless_pass_by_value)]
533    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
534    #![allow(clippy::disallowed_methods)]
535    use super::*;
536    use derive_more::From;
537
538    #[test]
539    fn bad_parse1() {
540        let mut err: RetryError<anyhow::Error> = RetryError::in_attempt_to("convert some things");
541        if let Err(e) = "maybe".parse::<bool>() {
542            err.push(e);
543        }
544        if let Err(e) = "a few".parse::<u32>() {
545            err.push(e);
546        }
547        if let Err(e) = "the_g1b50n".parse::<std::net::IpAddr>() {
548            err.push(e);
549        }
550
551        let disp = format!("{}", err);
552        assert_eq!(
553            disp,
554            "\
555Tried to convert some things 3 times, but all attempts failed
556Attempt 1: provided string was not `true` or `false`
557Attempt 2: invalid digit found in string
558Attempt 3: invalid IP address syntax"
559        );
560
561        let disp_alt = format!("{:#}", err);
562        assert!(disp_alt.contains("Tried to convert some things 3 times, but all attempts failed"));
563        assert!(disp_alt.contains("(from 20")); // Year prefix for timestamp
564    }
565
566    #[test]
567    fn no_problems() {
568        let empty: RetryError<anyhow::Error> =
569            RetryError::in_attempt_to("immanentize the eschaton");
570        let disp = format!("{}", empty);
571        assert_eq!(
572            disp,
573            "Unable to immanentize the eschaton. (No errors given)"
574        );
575    }
576
577    #[test]
578    fn one_problem() {
579        let mut err: RetryError<anyhow::Error> =
580            RetryError::in_attempt_to("connect to torproject.org");
581        if let Err(e) = "the_g1b50n".parse::<std::net::IpAddr>() {
582            err.push(e);
583        }
584        let disp = format!("{}", err);
585        assert_eq!(
586            disp,
587            "Unable to connect to torproject.org: invalid IP address syntax"
588        );
589
590        let disp_alt = format!("{:#}", err);
591        assert!(disp_alt.contains("Unable to connect to torproject.org at 20")); // Year prefix
592        assert!(disp_alt.contains("invalid IP address syntax"));
593    }
594
595    #[test]
596    fn operations() {
597        use std::num::ParseIntError;
598
599        #[derive(From, Clone, Debug, Eq, PartialEq)]
600        struct Wrapper(ParseIntError);
601
602        impl AsRef<dyn Error + 'static> for Wrapper {
603            fn as_ref(&self) -> &(dyn Error + 'static) {
604                &self.0
605            }
606        }
607
608        let mut err: RetryError<Wrapper> = RetryError::in_attempt_to("parse some integers");
609        assert!(err.is_empty());
610        assert_eq!(err.len(), 0);
611        err.extend(
612            vec!["not", "your", "number"]
613                .iter()
614                .filter_map(|s| s.parse::<u16>().err())
615                .map(Wrapper),
616        );
617        assert!(!err.is_empty());
618        assert_eq!(err.len(), 3);
619
620        let cloned = err.clone();
621        for (s1, s2) in err.sources().zip(cloned.sources()) {
622            assert_eq!(s1, s2);
623        }
624
625        err.dedup();
626
627        let disp = format!("{}", err);
628        assert_eq!(
629            disp,
630            "\
631Tried to parse some integers 3 times, but all attempts failed
632Attempts 1..3: invalid digit found in string"
633        );
634
635        let disp_alt = format!("{:#}", err);
636        assert!(disp_alt.contains("Tried to parse some integers 3 times, but all attempts failed"));
637        assert!(disp_alt.contains("(from 20")); // Year prefix for timestamp
638    }
639
640    #[test]
641    fn overflow() {
642        use std::num::ParseIntError;
643        let mut err: RetryError<ParseIntError> =
644            RetryError::in_attempt_to("parse too many integers");
645        assert!(err.is_empty());
646        let mut errors: Vec<ParseIntError> = vec!["no", "numbers"]
647            .iter()
648            .filter_map(|s| s.parse::<u16>().err())
649            .collect();
650        err.n_errors = usize::MAX;
651        err.errors.push((
652            Attempt::Range(1, err.n_errors),
653            errors.pop().expect("parser did not fail"),
654            Instant::now(),
655        ));
656        assert!(err.n_errors == usize::MAX);
657        assert!(err.len() == 1);
658
659        err.push(errors.pop().expect("parser did not fail"));
660        assert!(err.n_errors == usize::MAX);
661        assert!(err.len() == 1);
662    }
663
664    #[test]
665    fn extend_from_retry_preserve_timestamps() {
666        let n1 = Instant::now();
667        let n2 = n1 + Duration::from_secs(10);
668        let n3 = n1 + Duration::from_secs(20);
669
670        let mut err1: RetryError<anyhow::Error> = RetryError::in_attempt_to("do first thing");
671        let mut err2: RetryError<anyhow::Error> = RetryError::in_attempt_to("do second thing");
672
673        err2.push_timed(anyhow::Error::msg("e1"), n1, None);
674        err2.push_timed(anyhow::Error::msg("e2"), n2, None);
675
676        // err1 is empty initially
677        assert!(err1.first_error_at.is_none());
678
679        err1.extend_from_retry_error(err2);
680
681        assert_eq!(err1.len(), 2);
682        // The timestamps should be preserved
683        assert_eq!(err1.errors[0].2, n1);
684        assert_eq!(err1.errors[1].2, n2);
685
686        // Add another error to err1 to ensure mixed sources work
687        err1.push_timed(anyhow::Error::msg("e3"), n3, None);
688        assert_eq!(err1.len(), 3);
689        assert_eq!(err1.errors[2].2, n3);
690    }
691
692    #[test]
693    fn extend_from_retry_preserve_ranges() {
694        let n1 = Instant::now();
695        let mut err1: RetryError<anyhow::Error> = RetryError::in_attempt_to("do thing 1");
696
697        // Push 2 errors
698        err1.push(anyhow::Error::msg("e1"));
699        err1.push(anyhow::Error::msg("e2"));
700        assert_eq!(err1.n_errors, 2);
701
702        let mut err2: RetryError<anyhow::Error> = RetryError::in_attempt_to("do thing 2");
703        // Push 3 identical errors to create a range
704        let _err_msg = anyhow::Error::msg("repeated");
705        err2.push_timed(anyhow::Error::msg("repeated"), n1, None);
706        err2.push_timed(anyhow::Error::msg("repeated"), n1, None);
707        err2.push_timed(anyhow::Error::msg("repeated"), n1, None);
708
709        // Dedup err2 so it has a range
710        err2.dedup_by(|e1, e2| e1.to_string() == e2.to_string());
711        assert_eq!(err2.len(), 1); // collapsed to 1 entry
712        match err2.errors[0].0 {
713            Attempt::Range(1, 3) => {}
714            _ => panic!("Expected range 1..3"),
715        }
716
717        // Extend err1 with err2
718        err1.extend_from_retry_error(err2);
719
720        assert_eq!(err1.len(), 3); // 2 singles + 1 range
721        assert_eq!(err1.n_errors, 5); // 2 + 3 = 5 total attempts
722
723        // Check the range indices
724        match err1.errors[2].0 {
725            Attempt::Range(3, 5) => {}
726            ref x => panic!("Expected range 3..5, got {:?}", x),
727        }
728    }
729}