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    /// Optional cap on TOTAL retry wall-time across all attempts of a single
53    /// operation. When set, retrying stops before a backoff sleep would push
54    /// elapsed time past this budget, so a long transient storm fails cleanly
55    /// (with the last error, resumable on an idempotent re-run) instead of
56    /// running the full attempt ladder. Unset (the default) preserves pure
57    /// attempt-count behavior. Publishers whose surrounding CI job has a hard
58    /// timeout should set this comfortably below that timeout.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub max_elapsed: Option<HumanDuration>,
61}
62
63impl RetryConfig {
64    /// Default attempt count (10).
65    pub const DEFAULT_ATTEMPTS: u32 = 10;
66    /// Default initial delay (10s).
67    pub const DEFAULT_DELAY: std::time::Duration = std::time::Duration::from_secs(10);
68    /// Default delay cap (5m).
69    pub const DEFAULT_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(5 * 60);
70
71    /// Bridge to the internal [`RetryPolicy`] consumed by
72    /// [`crate::retry::retry_sync`] / [`crate::retry::retry_async`].
73    ///
74    /// If `max_delay < delay`, every backoff is immediately capped to
75    /// `max_delay`. This is parity-correct passthrough
76    /// but almost certainly a config mistake, so a `tracing::warn!` fires
77    /// once at conversion time to surface the issue in logs.
78    pub fn to_policy(&self) -> RetryPolicy {
79        if self.max_delay.duration() < self.delay.duration() {
80            tracing::warn!(
81                "retry.max_delay ({:?}) is less than retry.delay ({:?}); \
82                 backoff will be capped at max_delay",
83                self.max_delay.duration(),
84                self.delay.duration(),
85            );
86        }
87        RetryPolicy {
88            max_attempts: self.attempts.max(1),
89            base_delay: self.delay.duration(),
90            max_delay: self.max_delay.duration(),
91        }
92    }
93
94    /// The configured total-retry-wall-time budget, if any.
95    pub fn max_elapsed_duration(&self) -> Option<std::time::Duration> {
96        self.max_elapsed.map(|h| h.duration())
97    }
98}
99
100impl Default for RetryConfig {
101    fn default() -> Self {
102        Self {
103            attempts: Self::DEFAULT_ATTEMPTS,
104            delay: HumanDuration(Self::DEFAULT_DELAY),
105            max_delay: HumanDuration(Self::DEFAULT_MAX_DELAY),
106            max_elapsed: None,
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn defaults_match_goreleaser() {
117        let c = RetryConfig::default();
118        assert_eq!(c.attempts, 10);
119        assert_eq!(c.delay.duration(), std::time::Duration::from_secs(10));
120        assert_eq!(c.max_delay.duration(), std::time::Duration::from_secs(300));
121    }
122
123    #[test]
124    fn empty_yaml_yields_defaults() {
125        let c: RetryConfig = serde_yaml_ng::from_str("{}").unwrap();
126        assert_eq!(c.attempts, 10);
127        assert_eq!(c.delay.duration(), std::time::Duration::from_secs(10));
128        assert_eq!(c.max_delay.duration(), std::time::Duration::from_secs(300));
129    }
130
131    #[test]
132    fn parses_explicit_yaml() {
133        let yaml = r#"
134attempts: 5
135delay: 1s
136max_delay: 30s
137"#;
138        let c: RetryConfig = serde_yaml_ng::from_str(yaml).unwrap();
139        assert_eq!(c.attempts, 5);
140        assert_eq!(c.delay.duration(), std::time::Duration::from_secs(1));
141        assert_eq!(c.max_delay.duration(), std::time::Duration::from_secs(30));
142    }
143
144    #[test]
145    fn parses_compound_humantime() {
146        let yaml = r#"
147attempts: 3
148delay: 500ms
149max_delay: 1h30m
150"#;
151        let c: RetryConfig = serde_yaml_ng::from_str(yaml).unwrap();
152        assert_eq!(c.delay.duration(), std::time::Duration::from_millis(500));
153        assert_eq!(
154            c.max_delay.duration(),
155            std::time::Duration::from_secs(90 * 60),
156        );
157    }
158
159    #[test]
160    fn rejects_unknown_fields() {
161        let yaml = "bogus: 1";
162        let result: Result<RetryConfig, _> = serde_yaml_ng::from_str(yaml);
163        assert!(result.is_err(), "expected deny_unknown_fields to reject");
164    }
165
166    #[test]
167    fn to_policy_round_trip_defaults() {
168        let policy = RetryConfig::default().to_policy();
169        assert_eq!(policy.max_attempts, 10);
170        assert_eq!(policy.base_delay, std::time::Duration::from_secs(10));
171        assert_eq!(policy.max_delay, std::time::Duration::from_secs(300));
172    }
173
174    #[test]
175    fn to_policy_clamps_zero_attempts_to_one() {
176        let c = RetryConfig {
177            attempts: 0,
178            delay: HumanDuration(std::time::Duration::from_secs(1)),
179            max_delay: HumanDuration(std::time::Duration::from_secs(2)),
180            max_elapsed: None,
181        };
182        assert_eq!(c.to_policy().max_attempts, 1);
183    }
184
185    #[test]
186    fn to_policy_max_delay_below_delay_does_not_panic() {
187        // Invalid config (max_delay < delay) is parity-correct passthrough:
188        // every backoff is immediately capped at max_delay. The conversion
189        // emits a tracing::warn! but must not panic.
190        let c = RetryConfig {
191            attempts: 3,
192            delay: HumanDuration(std::time::Duration::from_secs(10)),
193            max_delay: HumanDuration(std::time::Duration::from_secs(1)),
194            max_elapsed: None,
195        };
196        let p = c.to_policy();
197        assert_eq!(p.max_attempts, 3);
198        assert_eq!(p.base_delay, std::time::Duration::from_secs(10));
199        assert_eq!(p.max_delay, std::time::Duration::from_secs(1));
200    }
201
202    #[test]
203    fn to_policy_preserves_custom_values() {
204        let c = RetryConfig {
205            attempts: 4,
206            delay: HumanDuration(std::time::Duration::from_millis(250)),
207            max_delay: HumanDuration(std::time::Duration::from_secs(7)),
208            max_elapsed: None,
209        };
210        let p = c.to_policy();
211        assert_eq!(p.max_attempts, 4);
212        assert_eq!(p.base_delay, std::time::Duration::from_millis(250));
213        assert_eq!(p.max_delay, std::time::Duration::from_secs(7));
214    }
215
216    #[test]
217    fn max_elapsed_defaults_to_none() {
218        let c = RetryConfig::default();
219        assert_eq!(c.max_elapsed, None);
220        assert_eq!(c.max_elapsed_duration(), None);
221    }
222
223    #[test]
224    fn max_elapsed_parses_and_other_fields_default() {
225        let c: RetryConfig = serde_yaml_ng::from_str("max_elapsed: 15m").unwrap();
226        assert_eq!(
227            c.max_elapsed_duration(),
228            Some(std::time::Duration::from_secs(15 * 60))
229        );
230        // Unspecified fields fall back to defaults.
231        assert_eq!(c.attempts, 10);
232        assert_eq!(c.delay.duration(), std::time::Duration::from_secs(10));
233        assert_eq!(c.max_delay.duration(), std::time::Duration::from_secs(300));
234    }
235
236    #[test]
237    fn max_elapsed_unset_leaves_accessor_none() {
238        let c: RetryConfig = serde_yaml_ng::from_str("attempts: 5").unwrap();
239        assert_eq!(c.max_elapsed_duration(), None);
240    }
241}