Skip to main content

api_bones/
retry.rs

1//! Retry primitives: policy, backoff strategies, `Retry-After` parsing, and
2//! the `Idempotent` marker trait.
3//!
4//! # Overview
5//!
6//! | Type / Trait       | Issue | Purpose                                               |
7//! |--------------------|-------|-------------------------------------------------------|
8//! | [`RetryPolicy`]    | #112  | Max-attempt cap + backoff strategy                    |
9//! | [`BackoffStrategy`]| #112  | `Fixed`, `Exponential`, or `DecorrelatedJitter` delays |
10//! | [`RetryAfter`]     | #113  | Parse `Retry-After` header (delta-seconds or date)    |
11//! | [`Idempotent`]     | #114  | Marker trait for request types safe to retry          |
12//!
13//! # Examples
14//!
15//! ```rust
16//! use api_bones::retry::{BackoffStrategy, Idempotent, RetryPolicy};
17//! use core::time::Duration;
18//!
19//! struct GetUser { id: u64 }
20//! impl Idempotent for GetUser {}
21//!
22//! fn should_retry<R: Idempotent>(req: &R, policy: &RetryPolicy, attempt: u32) -> bool {
23//!     attempt < policy.max_attempts
24//! }
25//!
26//! let policy = RetryPolicy::exponential(3, Duration::from_millis(100));
27//! let delay = policy.next_delay(1);
28//! assert!(delay >= Duration::from_millis(100));
29//! ```
30
31use core::time::Duration;
32
33// ---------------------------------------------------------------------------
34// BackoffStrategy
35// ---------------------------------------------------------------------------
36
37/// Backoff strategy used by [`RetryPolicy`] to compute inter-attempt delays.
38///
39/// | Variant              | Description                                           |
40/// |----------------------|-------------------------------------------------------|
41/// | `Fixed`              | Always wait the same `base` duration                 |
42/// | `Exponential`        | `base * 2^attempt` (capped at `max_delay`)           |
43/// | `DecorrelatedJitter` | Jittered delay in `[base, prev * 3]` (AWS algorithm) |
44#[derive(Debug, Clone, PartialEq, Eq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
47#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
48pub enum BackoffStrategy {
49    /// Return `base` unconditionally on every attempt.
50    Fixed {
51        /// Constant delay between attempts.
52        base: Duration,
53    },
54    /// Double the delay on each attempt, capping at `max_delay`.
55    ///
56    /// `delay(attempt) = min(base * 2^attempt, max_delay)`
57    Exponential {
58        /// Initial delay (attempt 0).
59        base: Duration,
60        /// Upper bound for computed delays.
61        max_delay: Duration,
62    },
63    /// Decorrelated jitter as described by AWS:
64    /// `delay = random_between(base, prev_delay * 3)`.
65    ///
66    /// [`RetryPolicy::next_delay`] uses a deterministic approximation
67    /// (`base + (prev_delay * 3 - base) / 2`) so that the type needs no
68    /// random-number-generator dependency.  Callers that want true
69    /// randomness should implement jitter on top using the returned duration
70    /// as an upper bound.
71    DecorrelatedJitter {
72        /// Minimum delay floor.
73        base: Duration,
74        /// Upper bound for any single delay.
75        max_delay: Duration,
76    },
77}
78
79// ---------------------------------------------------------------------------
80// RetryPolicy  (#112)
81// ---------------------------------------------------------------------------
82
83/// Retry policy combining a maximum-attempt cap with a [`BackoffStrategy`].
84///
85/// # Integration hints
86///
87/// - **reqwest-retry** (`reqwest-middleware`): implement `RetryableStrategy`
88///   and call [`RetryPolicy::next_delay`] from `retry_decision`.
89/// - **tower-retry**: implement `tower::retry::Policy` and delegate to
90///   [`RetryPolicy::next_delay`] inside `retry`.
91///
92/// # Examples
93///
94/// ```rust
95/// use api_bones::retry::RetryPolicy;
96/// use core::time::Duration;
97///
98/// // Fixed: always wait 500 ms, up to 5 attempts.
99/// let fixed = RetryPolicy::fixed(5, Duration::from_millis(500));
100/// assert_eq!(fixed.next_delay(0), Duration::from_millis(500));
101/// assert_eq!(fixed.next_delay(4), Duration::from_millis(500));
102///
103/// // Exponential: 100 ms → 200 ms → 400 ms … capped at 2 s.
104/// let exp = RetryPolicy::exponential(4, Duration::from_millis(100));
105/// assert_eq!(exp.next_delay(0), Duration::from_millis(100));
106/// assert_eq!(exp.next_delay(1), Duration::from_millis(200));
107/// assert_eq!(exp.next_delay(2), Duration::from_millis(400));
108/// ```
109#[derive(Debug, Clone, PartialEq, Eq)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
111#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
112#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
113pub struct RetryPolicy {
114    /// Maximum number of retry attempts (excluding the original request).
115    pub max_attempts: u32,
116    /// Backoff strategy used to compute delays between attempts.
117    pub strategy: BackoffStrategy,
118}
119
120impl RetryPolicy {
121    /// Create a policy with a [`BackoffStrategy::Fixed`] delay.
122    ///
123    /// # Examples
124    ///
125    /// ```rust
126    /// use api_bones::retry::RetryPolicy;
127    /// use core::time::Duration;
128    ///
129    /// let p = RetryPolicy::fixed(3, Duration::from_secs(1));
130    /// assert_eq!(p.max_attempts, 3);
131    /// assert_eq!(p.next_delay(99), Duration::from_secs(1));
132    /// ```
133    #[must_use]
134    pub fn fixed(max_attempts: u32, base: Duration) -> Self {
135        Self {
136            max_attempts,
137            strategy: BackoffStrategy::Fixed { base },
138        }
139    }
140
141    /// Create a policy with a [`BackoffStrategy::Exponential`] delay
142    /// capped at `base * 2^10` (≈ 1024× the base).
143    ///
144    /// # Examples
145    ///
146    /// ```rust
147    /// use api_bones::retry::RetryPolicy;
148    /// use core::time::Duration;
149    ///
150    /// let p = RetryPolicy::exponential(5, Duration::from_millis(50));
151    /// assert_eq!(p.next_delay(0), Duration::from_millis(50));
152    /// assert_eq!(p.next_delay(1), Duration::from_millis(100));
153    /// ```
154    #[must_use]
155    pub fn exponential(max_attempts: u32, base: Duration) -> Self {
156        // Default cap: 1024 × base (same as many HTTP client defaults).
157        let max_delay = base * 1024;
158        Self {
159            max_attempts,
160            strategy: BackoffStrategy::Exponential { base, max_delay },
161        }
162    }
163
164    /// Create a policy with a [`BackoffStrategy::DecorrelatedJitter`] delay.
165    ///
166    /// # Examples
167    ///
168    /// ```rust
169    /// use api_bones::retry::RetryPolicy;
170    /// use core::time::Duration;
171    ///
172    /// let p = RetryPolicy::decorrelated_jitter(4, Duration::from_millis(100));
173    /// let d = p.next_delay(1);
174    /// assert!(d >= Duration::from_millis(100));
175    /// ```
176    #[must_use]
177    pub fn decorrelated_jitter(max_attempts: u32, base: Duration) -> Self {
178        let max_delay = base * 1024;
179        Self {
180            max_attempts,
181            strategy: BackoffStrategy::DecorrelatedJitter { base, max_delay },
182        }
183    }
184
185    /// Compute the delay to wait before attempt number `attempt`.
186    ///
187    /// `attempt` is **0-indexed**: pass `0` before the first retry,
188    /// `1` before the second, and so on.
189    ///
190    /// For [`BackoffStrategy::DecorrelatedJitter`] this function returns the
191    /// **midpoint** of `[base, min(base * 3^(attempt+1), max_delay)]` as a
192    /// deterministic approximation.  Callers wanting true randomness should
193    /// use the returned value as an upper bound and sample uniformly in
194    /// `[base, returned_delay]`.
195    ///
196    /// # Examples
197    ///
198    /// ```rust
199    /// use api_bones::retry::RetryPolicy;
200    /// use core::time::Duration;
201    ///
202    /// let policy = RetryPolicy::exponential(5, Duration::from_millis(100));
203    /// assert_eq!(policy.next_delay(0), Duration::from_millis(100));
204    /// assert_eq!(policy.next_delay(1), Duration::from_millis(200));
205    /// assert_eq!(policy.next_delay(2), Duration::from_millis(400));
206    /// // Capped at base * 1024 = 102_400 ms
207    /// assert_eq!(policy.next_delay(20), Duration::from_millis(102_400));
208    /// ```
209    #[must_use]
210    pub fn next_delay(&self, attempt: u32) -> Duration {
211        match &self.strategy {
212            BackoffStrategy::Fixed { base } => *base,
213            BackoffStrategy::Exponential { base, max_delay } => {
214                // Saturating shift to avoid overflow on large `attempt` values.
215                let multiplier = 1_u64.checked_shl(attempt).unwrap_or(u64::MAX);
216                let delay = base.saturating_mul(u32::try_from(multiplier).unwrap_or(u32::MAX));
217                delay.min(*max_delay)
218            }
219            BackoffStrategy::DecorrelatedJitter { base, max_delay } => {
220                // Deterministic midpoint approximation:
221                // upper = min(base * 3^(attempt+1), max_delay)
222                // result = base + (upper - base) / 2
223                let mut upper = *base;
224                for _ in 0..=attempt {
225                    upper = upper.saturating_mul(3).min(*max_delay);
226                }
227                let half_range = upper.saturating_sub(*base) / 2;
228                (*base + half_range).min(*max_delay)
229            }
230        }
231    }
232}
233
234// ---------------------------------------------------------------------------
235// RetryAfter  (#113)
236// ---------------------------------------------------------------------------
237
238/// Parsed value of an HTTP `Retry-After` response header.
239///
240/// The header supports two forms (RFC 9110 §10.2.3):
241/// - **Delta-seconds**: a non-negative integer — `Retry-After: 120`
242/// - **HTTP-date**: an RFC 7231 date — `Retry-After: Wed, 21 Oct 2015 07:28:00 GMT`
243///
244/// # Parsing
245///
246/// ```rust
247/// use api_bones::retry::RetryAfter;
248/// use core::time::Duration;
249///
250/// let delay: RetryAfter = "120".parse().unwrap();
251/// assert_eq!(delay, RetryAfter::Delay(Duration::from_secs(120)));
252///
253/// let date: RetryAfter = "Wed, 21 Oct 2015 07:28:00 GMT".parse().unwrap();
254/// matches!(date, RetryAfter::Date(_));
255/// ```
256#[cfg(any(feature = "chrono", feature = "std", feature = "alloc"))]
257#[derive(Debug, Clone, PartialEq, Eq)]
258#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
259pub enum RetryAfter {
260    /// A relative delay expressed as a [`Duration`].
261    Delay(Duration),
262    /// An absolute point-in-time expressed as an RFC 7231 HTTP-date string.
263    ///
264    /// Stored as a raw string to avoid a mandatory `chrono` dependency.
265    /// Parse it with [`chrono::DateTime::parse_from_rfc2822`] when the
266    /// `chrono` feature is enabled.
267    Date(
268        #[cfg_attr(feature = "serde", serde(rename = "date"))]
269        /// Raw HTTP-date string (RFC 7231 / RFC 5322 format).
270        RetryAfterDate,
271    ),
272}
273
274/// Inner date representation for [`RetryAfter::Date`].
275///
276/// When the `chrono` feature is enabled this is a
277/// `chrono::DateTime<chrono::FixedOffset>`; otherwise it is a raw `&'static`-
278/// free heap string (requires `alloc` or `std`).
279#[cfg(feature = "chrono")]
280pub type RetryAfterDate = chrono::DateTime<chrono::FixedOffset>;
281
282/// Inner date representation for [`RetryAfter::Date`] (string fallback).
283#[cfg(all(not(feature = "chrono"), any(feature = "std", feature = "alloc")))]
284#[cfg(not(feature = "std"))]
285pub type RetryAfterDate = alloc::string::String;
286
287/// Inner date representation for [`RetryAfter::Date`] (string fallback, std).
288#[cfg(all(not(feature = "chrono"), feature = "std"))]
289pub type RetryAfterDate = std::string::String;
290
291// ---------------------------------------------------------------------------
292// RetryAfterParseError
293// ---------------------------------------------------------------------------
294
295/// Error returned when a `Retry-After` header value cannot be parsed.
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct RetryAfterParseError(
298    #[cfg(any(feature = "std", feature = "alloc"))]
299    #[cfg_attr(not(feature = "std"), allow(dead_code))]
300    RetryAfterParseErrorInner,
301);
302
303#[cfg(any(feature = "std", feature = "alloc"))]
304#[derive(Debug, Clone, PartialEq, Eq)]
305enum RetryAfterParseErrorInner {
306    #[cfg(feature = "chrono")]
307    InvalidDate(chrono::ParseError),
308    #[cfg(not(feature = "chrono"))]
309    InvalidFormat,
310}
311
312impl core::fmt::Display for RetryAfterParseError {
313    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
314        #[cfg(any(feature = "std", feature = "alloc"))]
315        match &self.0 {
316            #[cfg(feature = "chrono")]
317            RetryAfterParseErrorInner::InvalidDate(e) => {
318                write!(f, "invalid Retry-After date: {e}")
319            }
320            #[cfg(not(feature = "chrono"))]
321            RetryAfterParseErrorInner::InvalidFormat => {
322                f.write_str("Retry-After value must be delta-seconds or an HTTP-date")
323            }
324        }
325        #[cfg(not(any(feature = "std", feature = "alloc")))]
326        f.write_str("invalid Retry-After value")
327    }
328}
329
330#[cfg(feature = "std")]
331impl std::error::Error for RetryAfterParseError {
332    #[cfg(feature = "chrono")]
333    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
334        match &self.0 {
335            RetryAfterParseErrorInner::InvalidDate(e) => Some(e),
336        }
337    }
338}
339
340// ---------------------------------------------------------------------------
341// FromStr for RetryAfter
342// ---------------------------------------------------------------------------
343
344#[cfg(any(feature = "std", feature = "alloc"))]
345impl core::str::FromStr for RetryAfter {
346    type Err = RetryAfterParseError;
347
348    /// Parse a `Retry-After` header value.
349    ///
350    /// Tries delta-seconds first; falls back to HTTP-date.
351    ///
352    /// # Errors
353    ///
354    /// Returns [`RetryAfterParseError`] when the value is neither a valid
355    /// non-negative integer nor a parseable HTTP-date string.
356    fn from_str(s: &str) -> Result<Self, Self::Err> {
357        let trimmed = s.trim();
358
359        // --- delta-seconds ---
360        if let Ok(secs) = trimmed.parse::<u64>() {
361            return Ok(Self::Delay(Duration::from_secs(secs)));
362        }
363
364        // --- HTTP-date (RFC 7231 / RFC 5322) ---
365        #[cfg(feature = "chrono")]
366        {
367            chrono::DateTime::parse_from_rfc2822(trimmed)
368                .map(Self::Date)
369                .map_err(|e| RetryAfterParseError(RetryAfterParseErrorInner::InvalidDate(e)))
370        }
371
372        #[cfg(not(feature = "chrono"))]
373        {
374            // Without chrono we accept any non-empty string as a raw date.
375            if trimmed.is_empty() {
376                Err(RetryAfterParseError(
377                    RetryAfterParseErrorInner::InvalidFormat,
378                ))
379            } else {
380                Ok(Self::Date(trimmed.into()))
381            }
382        }
383    }
384}
385
386// ---------------------------------------------------------------------------
387// Display for RetryAfter
388// ---------------------------------------------------------------------------
389
390#[cfg(any(feature = "std", feature = "alloc"))]
391impl core::fmt::Display for RetryAfter {
392    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
393        match self {
394            Self::Delay(d) => write!(f, "{}", d.as_secs()),
395            #[cfg(feature = "chrono")]
396            Self::Date(dt) => write!(f, "{}", dt.to_rfc2822()),
397            #[cfg(not(feature = "chrono"))]
398            Self::Date(s) => f.write_str(s),
399        }
400    }
401}
402
403// ---------------------------------------------------------------------------
404// Idempotent  (#114)
405// ---------------------------------------------------------------------------
406
407/// Marker trait for request types that are safe to retry.
408///
409/// A request is idempotent when repeating it produces the same observable
410/// side-effects as issuing it once (RFC 9110 §9.2.2).  Typical examples:
411/// `GET`, `HEAD`, `PUT`, `DELETE`.
412///
413/// Implement this trait on your request structs to opt into generic retry
414/// helpers that gate retries on idempotency:
415///
416/// ```rust
417/// use api_bones::retry::{Idempotent, RetryPolicy};
418/// use core::time::Duration;
419///
420/// struct DeleteResource { id: u64 }
421/// impl Idempotent for DeleteResource {}
422///
423/// fn maybe_retry<R: Idempotent>(policy: &RetryPolicy, attempt: u32) -> Option<Duration> {
424///     if attempt < policy.max_attempts {
425///         Some(policy.next_delay(attempt))
426///     } else {
427///         None
428///     }
429/// }
430///
431/// let policy = RetryPolicy::fixed(3, Duration::from_millis(500));
432/// let req = DeleteResource { id: 42 };
433/// let delay = maybe_retry::<DeleteResource>(&policy, 0);
434/// assert_eq!(delay, Some(Duration::from_millis(500)));
435/// ```
436pub trait Idempotent {}
437
438// ---------------------------------------------------------------------------
439// Tests
440// ---------------------------------------------------------------------------
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use core::time::Duration;
446
447    // -----------------------------------------------------------------------
448    // RetryPolicy – Fixed
449    // -----------------------------------------------------------------------
450
451    #[test]
452    fn fixed_delay_is_constant() {
453        let p = RetryPolicy::fixed(5, Duration::from_millis(250));
454        assert_eq!(p.next_delay(0), Duration::from_millis(250));
455        assert_eq!(p.next_delay(3), Duration::from_millis(250));
456        assert_eq!(p.next_delay(100), Duration::from_millis(250));
457    }
458
459    // -----------------------------------------------------------------------
460    // RetryPolicy – Exponential
461    // -----------------------------------------------------------------------
462
463    #[test]
464    fn exponential_doubles_each_attempt() {
465        let p = RetryPolicy::exponential(10, Duration::from_millis(100));
466        assert_eq!(p.next_delay(0), Duration::from_millis(100));
467        assert_eq!(p.next_delay(1), Duration::from_millis(200));
468        assert_eq!(p.next_delay(2), Duration::from_millis(400));
469        assert_eq!(p.next_delay(3), Duration::from_millis(800));
470    }
471
472    #[test]
473    fn exponential_caps_at_max_delay() {
474        let p = RetryPolicy::exponential(5, Duration::from_millis(100));
475        // base * 1024 = 102_400 ms
476        let cap = Duration::from_millis(100) * 1024;
477        assert_eq!(p.next_delay(100), cap);
478    }
479
480    #[test]
481    fn exponential_handles_overflow_gracefully() {
482        let p = RetryPolicy::exponential(5, Duration::from_secs(1));
483        // Very large attempt: must not panic, must be ≤ max_delay
484        let d = p.next_delay(u32::MAX);
485        assert!(d <= Duration::from_secs(1) * 1024);
486    }
487
488    // -----------------------------------------------------------------------
489    // RetryPolicy – DecorrelatedJitter
490    // -----------------------------------------------------------------------
491
492    #[test]
493    fn jitter_delay_gte_base() {
494        let base = Duration::from_millis(100);
495        let p = RetryPolicy::decorrelated_jitter(5, base);
496        for attempt in 0..10 {
497            assert!(
498                p.next_delay(attempt) >= base,
499                "attempt {attempt}: delay < base"
500            );
501        }
502    }
503
504    #[test]
505    fn jitter_delay_lte_max() {
506        let base = Duration::from_millis(100);
507        let p = RetryPolicy::decorrelated_jitter(5, base);
508        let max = base * 1024;
509        for attempt in 0..20 {
510            assert!(
511                p.next_delay(attempt) <= max,
512                "attempt {attempt}: delay > max_delay"
513            );
514        }
515    }
516
517    // -----------------------------------------------------------------------
518    // RetryAfter – parsing
519    // -----------------------------------------------------------------------
520
521    #[cfg(any(feature = "std", feature = "alloc"))]
522    #[test]
523    fn parse_delta_seconds() {
524        let r: RetryAfter = "120".parse().unwrap();
525        assert_eq!(r, RetryAfter::Delay(Duration::from_secs(120)));
526    }
527
528    #[cfg(any(feature = "std", feature = "alloc"))]
529    #[test]
530    fn parse_zero_seconds() {
531        let r: RetryAfter = "0".parse().unwrap();
532        assert_eq!(r, RetryAfter::Delay(Duration::ZERO));
533    }
534
535    #[cfg(all(any(feature = "std", feature = "alloc"), feature = "chrono"))]
536    #[test]
537    fn parse_http_date() {
538        let r: RetryAfter = "Wed, 21 Oct 2015 07:28:00 GMT".parse().unwrap();
539        assert!(matches!(r, RetryAfter::Date(_)));
540    }
541
542    #[cfg(all(any(feature = "std", feature = "alloc"), feature = "chrono"))]
543    #[test]
544    fn parse_invalid_returns_error() {
545        let r: Result<RetryAfter, _> = "not-a-valid-value".parse();
546        assert!(r.is_err());
547    }
548
549    // -----------------------------------------------------------------------
550    // RetryAfter – Display round-trip
551    // -----------------------------------------------------------------------
552
553    #[cfg(any(feature = "std", feature = "alloc"))]
554    #[test]
555    fn display_delay_round_trips() {
556        let r = RetryAfter::Delay(Duration::from_secs(60));
557        assert_eq!(r.to_string(), "60");
558    }
559
560    #[cfg(all(any(feature = "std", feature = "alloc"), feature = "chrono"))]
561    #[test]
562    fn display_date_round_trips() {
563        let original = "Wed, 21 Oct 2015 07:28:00 +0000";
564        let r: RetryAfter = original.parse().unwrap();
565        // to_string() calls to_rfc2822() — verify it re-parses cleanly
566        let back: RetryAfter = r.to_string().parse().unwrap();
567        assert_eq!(r, back);
568    }
569
570    // -----------------------------------------------------------------------
571    // RetryAfter – serde round-trip
572    // -----------------------------------------------------------------------
573
574    #[cfg(all(any(feature = "std", feature = "alloc"), feature = "serde"))]
575    #[test]
576    fn serde_delay_round_trip() {
577        let r = RetryAfter::Delay(Duration::from_secs(30));
578        let json = serde_json::to_value(&r).unwrap();
579        let back: RetryAfter = serde_json::from_value(json).unwrap();
580        assert_eq!(back, r);
581    }
582
583    // -----------------------------------------------------------------------
584    // Idempotent – compile-time marker
585    // -----------------------------------------------------------------------
586
587    struct GetItems;
588    impl Idempotent for GetItems {}
589
590    fn require_idempotent<R: Idempotent>(_: &R) {}
591
592    #[test]
593    fn idempotent_implementor_accepted_by_generic_fn() {
594        let req = GetItems;
595        require_idempotent(&req);
596    }
597
598    // -----------------------------------------------------------------------
599    // RetryAfterParseError – Display
600    // -----------------------------------------------------------------------
601
602    #[cfg(all(any(feature = "std", feature = "alloc"), feature = "chrono"))]
603    #[test]
604    fn retry_after_parse_error_display() {
605        let err: Result<RetryAfter, _> = "not-a-valid-date-or-number".parse();
606        let e = err.unwrap_err();
607        let s = e.to_string();
608        assert!(s.contains("invalid Retry-After"));
609    }
610
611    // -----------------------------------------------------------------------
612    // std::error::Error::source for RetryAfterParseError
613    // -----------------------------------------------------------------------
614
615    #[cfg(all(feature = "std", feature = "chrono"))]
616    #[test]
617    fn retry_after_parse_error_source() {
618        use std::error::Error;
619        let err: Result<RetryAfter, _> = "not-a-date".parse();
620        let e = err.unwrap_err();
621        // source() should return the underlying chrono parse error
622        assert!(e.source().is_some());
623    }
624}