Skip to main content

anodizer_core/config/
retry.rs

1//! Top-level `retry:` block — user-facing YAML configuration for the shared
2//! retry-with-backoff machinery.
3//!
4//! Project-level retry policy:
5//!
6//! ```yaml
7//! retry:
8//!   attempts: 10
9//!   delay: 10s
10//!   max_delay: 5m
11//! ```
12//!
13//! Defaults are `Attempts:10, Delay:10s, MaxDelay:5m`
14//! so that consumers see identical retry behaviour with the
15//! same YAML.
16//!
17//! [`RetryConfig::to_policy`] bridges the user-facing type to
18//! [`crate::retry::RetryPolicy`] which is what `retry_sync` / `retry_async`
19//! consume. The conversion fixes the multiplier at 2.0 (hard-coded in
20//! `RetryPolicy::delay_for`); a fixed 2× backoff is used via
21//! `retry.BackOffDelay`.
22//!
23//! ## See also
24//!
25//! - [`crate::retry`] — the policy + retry primitives.
26//! - [`crate::retry::is_retriable`] — companion predicate (network / 5xx /
27//!   429 / explicitly-marked retriable).
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31
32use super::HumanDuration;
33use crate::retry::RetryPolicy;
34
35/// User-facing retry configuration block (`retry:` at config root).
36///
37/// All fields are optional in YAML; missing fields fall back to the
38/// defaults (10 attempts, 10s base delay, 5m cap).
39#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
40#[serde(default, deny_unknown_fields)]
41pub struct RetryConfig {
42    /// Total attempts (including the first). Default `10`. Values < 1 are
43    /// clamped up to 1 by the policy layer.
44    pub attempts: u32,
45    /// Initial delay before the second attempt. Default `10s`. Subsequent
46    /// delays grow exponentially (`delay × 2^(n-2)`) up to [`Self::max_delay`].
47    pub delay: HumanDuration,
48    /// Upper bound on any individual sleep between attempts. Default `5m`.
49    /// Without this cap, an exponential backoff with `delay=10s` would
50    /// stretch attempt 9 to ~42 minutes.
51    pub max_delay: HumanDuration,
52}
53
54impl RetryConfig {
55    /// Default attempt count (10).
56    pub const DEFAULT_ATTEMPTS: u32 = 10;
57    /// Default initial delay (10s).
58    pub const DEFAULT_DELAY: std::time::Duration = std::time::Duration::from_secs(10);
59    /// Default delay cap (5m).
60    pub const DEFAULT_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(5 * 60);
61
62    /// Bridge to the internal [`RetryPolicy`] consumed by
63    /// [`crate::retry::retry_sync`] / [`crate::retry::retry_async`].
64    ///
65    /// If `max_delay < delay`, every backoff is immediately capped to
66    /// `max_delay`. This is parity-correct passthrough
67    /// but almost certainly a config mistake, so a `tracing::warn!` fires
68    /// once at conversion time to surface the issue in logs.
69    pub fn to_policy(&self) -> RetryPolicy {
70        if self.max_delay.duration() < self.delay.duration() {
71            tracing::warn!(
72                "retry.max_delay ({:?}) is less than retry.delay ({:?}); \
73                 backoff will be capped at max_delay",
74                self.max_delay.duration(),
75                self.delay.duration(),
76            );
77        }
78        RetryPolicy {
79            max_attempts: self.attempts.max(1),
80            base_delay: self.delay.duration(),
81            max_delay: self.max_delay.duration(),
82        }
83    }
84}
85
86impl Default for RetryConfig {
87    fn default() -> Self {
88        Self {
89            attempts: Self::DEFAULT_ATTEMPTS,
90            delay: HumanDuration(Self::DEFAULT_DELAY),
91            max_delay: HumanDuration(Self::DEFAULT_MAX_DELAY),
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn defaults_match_goreleaser() {
102        let c = RetryConfig::default();
103        assert_eq!(c.attempts, 10);
104        assert_eq!(c.delay.duration(), std::time::Duration::from_secs(10));
105        assert_eq!(c.max_delay.duration(), std::time::Duration::from_secs(300));
106    }
107
108    #[test]
109    fn empty_yaml_yields_defaults() {
110        let c: RetryConfig = serde_yaml_ng::from_str("{}").unwrap();
111        assert_eq!(c.attempts, 10);
112        assert_eq!(c.delay.duration(), std::time::Duration::from_secs(10));
113        assert_eq!(c.max_delay.duration(), std::time::Duration::from_secs(300));
114    }
115
116    #[test]
117    fn parses_explicit_yaml() {
118        let yaml = r#"
119attempts: 5
120delay: 1s
121max_delay: 30s
122"#;
123        let c: RetryConfig = serde_yaml_ng::from_str(yaml).unwrap();
124        assert_eq!(c.attempts, 5);
125        assert_eq!(c.delay.duration(), std::time::Duration::from_secs(1));
126        assert_eq!(c.max_delay.duration(), std::time::Duration::from_secs(30));
127    }
128
129    #[test]
130    fn parses_compound_humantime() {
131        let yaml = r#"
132attempts: 3
133delay: 500ms
134max_delay: 1h30m
135"#;
136        let c: RetryConfig = serde_yaml_ng::from_str(yaml).unwrap();
137        assert_eq!(c.delay.duration(), std::time::Duration::from_millis(500));
138        assert_eq!(
139            c.max_delay.duration(),
140            std::time::Duration::from_secs(90 * 60),
141        );
142    }
143
144    #[test]
145    fn rejects_unknown_fields() {
146        let yaml = "bogus: 1";
147        let result: Result<RetryConfig, _> = serde_yaml_ng::from_str(yaml);
148        assert!(result.is_err(), "expected deny_unknown_fields to reject");
149    }
150
151    #[test]
152    fn to_policy_round_trip_defaults() {
153        let policy = RetryConfig::default().to_policy();
154        assert_eq!(policy.max_attempts, 10);
155        assert_eq!(policy.base_delay, std::time::Duration::from_secs(10));
156        assert_eq!(policy.max_delay, std::time::Duration::from_secs(300));
157    }
158
159    #[test]
160    fn to_policy_clamps_zero_attempts_to_one() {
161        let c = RetryConfig {
162            attempts: 0,
163            delay: HumanDuration(std::time::Duration::from_secs(1)),
164            max_delay: HumanDuration(std::time::Duration::from_secs(2)),
165        };
166        assert_eq!(c.to_policy().max_attempts, 1);
167    }
168
169    #[test]
170    fn to_policy_max_delay_below_delay_does_not_panic() {
171        // Invalid config (max_delay < delay) is parity-correct passthrough:
172        // every backoff is immediately capped at max_delay. The conversion
173        // emits a tracing::warn! but must not panic.
174        let c = RetryConfig {
175            attempts: 3,
176            delay: HumanDuration(std::time::Duration::from_secs(10)),
177            max_delay: HumanDuration(std::time::Duration::from_secs(1)),
178        };
179        let p = c.to_policy();
180        assert_eq!(p.max_attempts, 3);
181        assert_eq!(p.base_delay, std::time::Duration::from_secs(10));
182        assert_eq!(p.max_delay, std::time::Duration::from_secs(1));
183    }
184
185    #[test]
186    fn to_policy_preserves_custom_values() {
187        let c = RetryConfig {
188            attempts: 4,
189            delay: HumanDuration(std::time::Duration::from_millis(250)),
190            max_delay: HumanDuration(std::time::Duration::from_secs(7)),
191        };
192        let p = c.to_policy();
193        assert_eq!(p.max_attempts, 4);
194        assert_eq!(p.base_delay, std::time::Duration::from_millis(250));
195        assert_eq!(p.max_delay, std::time::Duration::from_secs(7));
196    }
197}