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//! Mirrors GoReleaser's `Project.Retry` (`pkg/config/config.go::Retry`):
5//!
6//! ```yaml
7//! retry:
8//!   attempts: 10
9//!   delay: 10s
10//!   max_delay: 5m
11//! ```
12//!
13//! Defaults match GoReleaser exactly (`Retry{Attempts:10, Delay:10s, MaxDelay:5m}`)
14//! so that consumers porting from GR 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`); GR also uses a fixed 2× backoff 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 GoReleaser's
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 (matches GoReleaser `pkg/config.Retry.Attempts`).
56    pub const DEFAULT_ATTEMPTS: u32 = 10;
57    /// Default initial delay (matches GoReleaser `pkg/config.Retry.Delay = 10s`).
58    pub const DEFAULT_DELAY: std::time::Duration = std::time::Duration::from_secs(10);
59    /// Default delay cap (matches GoReleaser `pkg/config.Retry.MaxDelay = 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 (GR behaves the same)
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                delay = ?self.delay.duration(),
73                max_delay = ?self.max_delay.duration(),
74                "retry.max_delay is less than retry.delay; backoff will be capped at max_delay"
75            );
76        }
77        RetryPolicy {
78            max_attempts: self.attempts.max(1),
79            base_delay: self.delay.duration(),
80            max_delay: self.max_delay.duration(),
81        }
82    }
83}
84
85impl Default for RetryConfig {
86    fn default() -> Self {
87        Self {
88            attempts: Self::DEFAULT_ATTEMPTS,
89            delay: HumanDuration(Self::DEFAULT_DELAY),
90            max_delay: HumanDuration(Self::DEFAULT_MAX_DELAY),
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn defaults_match_goreleaser() {
101        let c = RetryConfig::default();
102        assert_eq!(c.attempts, 10);
103        assert_eq!(c.delay.duration(), std::time::Duration::from_secs(10));
104        assert_eq!(c.max_delay.duration(), std::time::Duration::from_secs(300));
105    }
106
107    #[test]
108    fn empty_yaml_yields_defaults() {
109        let c: RetryConfig = serde_yaml_ng::from_str("{}").unwrap();
110        assert_eq!(c.attempts, 10);
111        assert_eq!(c.delay.duration(), std::time::Duration::from_secs(10));
112        assert_eq!(c.max_delay.duration(), std::time::Duration::from_secs(300));
113    }
114
115    #[test]
116    fn parses_explicit_yaml() {
117        let yaml = r#"
118attempts: 5
119delay: 1s
120max_delay: 30s
121"#;
122        let c: RetryConfig = serde_yaml_ng::from_str(yaml).unwrap();
123        assert_eq!(c.attempts, 5);
124        assert_eq!(c.delay.duration(), std::time::Duration::from_secs(1));
125        assert_eq!(c.max_delay.duration(), std::time::Duration::from_secs(30));
126    }
127
128    #[test]
129    fn parses_compound_humantime() {
130        let yaml = r#"
131attempts: 3
132delay: 500ms
133max_delay: 1h30m
134"#;
135        let c: RetryConfig = serde_yaml_ng::from_str(yaml).unwrap();
136        assert_eq!(c.delay.duration(), std::time::Duration::from_millis(500));
137        assert_eq!(
138            c.max_delay.duration(),
139            std::time::Duration::from_secs(90 * 60),
140        );
141    }
142
143    #[test]
144    fn rejects_unknown_fields() {
145        let yaml = "bogus: 1";
146        let result: Result<RetryConfig, _> = serde_yaml_ng::from_str(yaml);
147        assert!(result.is_err(), "expected deny_unknown_fields to reject");
148    }
149
150    #[test]
151    fn to_policy_round_trip_defaults() {
152        let policy = RetryConfig::default().to_policy();
153        assert_eq!(policy.max_attempts, 10);
154        assert_eq!(policy.base_delay, std::time::Duration::from_secs(10));
155        assert_eq!(policy.max_delay, std::time::Duration::from_secs(300));
156    }
157
158    #[test]
159    fn to_policy_clamps_zero_attempts_to_one() {
160        let c = RetryConfig {
161            attempts: 0,
162            delay: HumanDuration(std::time::Duration::from_secs(1)),
163            max_delay: HumanDuration(std::time::Duration::from_secs(2)),
164        };
165        assert_eq!(c.to_policy().max_attempts, 1);
166    }
167
168    #[test]
169    fn to_policy_max_delay_below_delay_does_not_panic() {
170        // Invalid config (max_delay < delay) is parity-correct passthrough:
171        // every backoff is immediately capped at max_delay. The conversion
172        // emits a tracing::warn! but must not panic.
173        let c = RetryConfig {
174            attempts: 3,
175            delay: HumanDuration(std::time::Duration::from_secs(10)),
176            max_delay: HumanDuration(std::time::Duration::from_secs(1)),
177        };
178        let p = c.to_policy();
179        assert_eq!(p.max_attempts, 3);
180        assert_eq!(p.base_delay, std::time::Duration::from_secs(10));
181        assert_eq!(p.max_delay, std::time::Duration::from_secs(1));
182    }
183
184    #[test]
185    fn to_policy_preserves_custom_values() {
186        let c = RetryConfig {
187            attempts: 4,
188            delay: HumanDuration(std::time::Duration::from_millis(250)),
189            max_delay: HumanDuration(std::time::Duration::from_secs(7)),
190        };
191        let p = c.to_policy();
192        assert_eq!(p.max_attempts, 4);
193        assert_eq!(p.base_delay, std::time::Duration::from_millis(250));
194        assert_eq!(p.max_delay, std::time::Duration::from_secs(7));
195    }
196}