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