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