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