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}