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