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