Skip to main content

solti_model/domain/policy/
backoff.rs

1//! # Backoff policy.
2//!
3//! [`BackoffPolicy`] controls retry delay growth: initial delay, max cap, factor, and jitter.
4
5use std::borrow::Cow;
6use std::hash::{Hash, Hasher};
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::{ModelError, ModelResult};
11
12/// Exponential backoff configuration for task restart delays.
13///
14/// | Field      | Type           | Default   | Description                           |
15/// |------------|----------------|-----------|---------------------------------------|
16/// | `jitter`   | `JitterPolicy` | `Full`    | Randomness applied to each delay      |
17/// | `first_ms` | `u64`          | `1_000`   | Initial delay (ms)                    |
18/// | `max_ms`   | `u64`          | `30_000`  | Maximum delay cap (ms)                |
19/// | `factor`   | `f64`          | `2.0`     | Exponential growth multiplier         |
20///
21/// Growth example with `factor = 2.0`: 1 s → 2 s → 4 s → 8 s → … → 30 s (capped).
22///
23/// ## Also
24///
25/// - [`JitterPolicy`](super::JitterPolicy) jitter strategy applied to each delay.
26/// - [`RestartPolicy`](super::RestartPolicy) controls *when* to restart; backoff controls *delay*.
27/// - [`BackoffPolicy::validate`] parameter validation.
28#[derive(Clone, Debug, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30#[serde(try_from = "raw::BackoffPolicyRaw")]
31pub struct BackoffPolicy {
32    /// Jitter policy applied to each computed delay.
33    pub jitter: super::JitterPolicy,
34    /// Initial delay (ms) for exponential backoff.
35    pub first_ms: u64,
36    /// Maximum allowed delay (ms).
37    pub max_ms: u64,
38    /// Exponential growth multiplier.
39    pub factor: f64,
40}
41
42mod raw {
43    use super::*;
44
45    #[derive(Deserialize)]
46    #[serde(rename_all = "camelCase")]
47    pub(super) struct BackoffPolicyRaw {
48        pub jitter: super::super::JitterPolicy,
49        pub first_ms: u64,
50        pub max_ms: u64,
51        pub factor: f64,
52    }
53
54    impl TryFrom<BackoffPolicyRaw> for BackoffPolicy {
55        type Error = ModelError;
56
57        fn try_from(r: BackoffPolicyRaw) -> Result<Self, Self::Error> {
58            let p = BackoffPolicy {
59                jitter: r.jitter,
60                first_ms: r.first_ms,
61                max_ms: r.max_ms,
62                factor: r.factor,
63            };
64            p.validate()?;
65            Ok(p)
66        }
67    }
68}
69
70impl BackoffPolicy {
71    /// Validate backoff parameters.
72    ///
73    /// Checks:
74    /// - `first_ms > 0`
75    /// - `max_ms >= first_ms`
76    /// - `factor >= 1.0` and finite
77    pub fn validate(&self) -> ModelResult<()> {
78        if self.first_ms == 0 {
79            return Err(ModelError::Invalid(Cow::Borrowed(
80                "backoff first_ms must be greater than zero",
81            )));
82        }
83        if self.max_ms < self.first_ms {
84            return Err(ModelError::Invalid(Cow::Borrowed(
85                "backoff max_ms must be >= first_ms",
86            )));
87        }
88        if !self.factor.is_finite() || self.factor < 1.0 {
89            return Err(ModelError::Invalid(Cow::Borrowed(
90                "backoff factor must be finite and >= 1.0",
91            )));
92        }
93        Ok(())
94    }
95}
96
97impl PartialEq for BackoffPolicy {
98    fn eq(&self, other: &Self) -> bool {
99        self.jitter == other.jitter
100            && self.factor.to_bits() == other.factor.to_bits()
101            && self.first_ms == other.first_ms
102            && self.max_ms == other.max_ms
103    }
104}
105
106impl Eq for BackoffPolicy {}
107
108impl Hash for BackoffPolicy {
109    fn hash<H: Hasher>(&self, state: &mut H) {
110        self.factor.to_bits().hash(state);
111        self.first_ms.hash(state);
112        self.jitter.hash(state);
113        self.max_ms.hash(state);
114    }
115}
116
117impl Default for BackoffPolicy {
118    /// Returns a sensible default: full jitter, 1s initial, 30s max, factor 2.
119    fn default() -> Self {
120        Self {
121            jitter: super::JitterPolicy::Full,
122            first_ms: 1_000,
123            max_ms: 30_000,
124            factor: 2.0,
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn validate_accepts_defaults() {
135        assert!(BackoffPolicy::default().validate().is_ok());
136    }
137
138    #[test]
139    fn validate_rejects_zero_first_ms() {
140        let p = BackoffPolicy {
141            first_ms: 0,
142            ..BackoffPolicy::default()
143        };
144        assert!(p.validate().is_err());
145    }
146
147    #[test]
148    fn validate_rejects_max_smaller_than_first() {
149        let p = BackoffPolicy {
150            first_ms: 500,
151            max_ms: 100,
152            ..BackoffPolicy::default()
153        };
154        assert!(p.validate().is_err());
155    }
156
157    #[test]
158    fn validate_rejects_factor_below_one() {
159        let p = BackoffPolicy {
160            factor: 0.5,
161            ..BackoffPolicy::default()
162        };
163        assert!(p.validate().is_err());
164    }
165
166    #[test]
167    fn validate_rejects_nan_factor() {
168        let p = BackoffPolicy {
169            factor: f64::NAN,
170            ..BackoffPolicy::default()
171        };
172        assert!(p.validate().is_err());
173    }
174
175    #[test]
176    fn serde_roundtrip_accepts_valid() {
177        let p = BackoffPolicy::default();
178        let json = serde_json::to_string(&p).unwrap();
179        let back: BackoffPolicy = serde_json::from_str(&json).unwrap();
180        assert_eq!(back, p);
181    }
182
183    #[test]
184    fn serde_rejects_invalid_first_ms_on_deserialize() {
185        let json = r#"{"jitter":"full","firstMs":0,"maxMs":30000,"factor":2.0}"#;
186        let err = serde_json::from_str::<BackoffPolicy>(json).unwrap_err();
187        assert!(err.to_string().contains("first_ms"), "got: {err}");
188    }
189
190    #[test]
191    fn serde_rejects_inverted_max_on_deserialize() {
192        let json = r#"{"jitter":"full","firstMs":1000,"maxMs":500,"factor":2.0}"#;
193        let err = serde_json::from_str::<BackoffPolicy>(json).unwrap_err();
194        assert!(err.to_string().contains("max_ms"), "got: {err}");
195    }
196
197    #[test]
198    fn serde_rejects_factor_below_one_on_deserialize() {
199        let json = r#"{"jitter":"full","firstMs":1000,"maxMs":30000,"factor":0.5}"#;
200        let err = serde_json::from_str::<BackoffPolicy>(json).unwrap_err();
201        assert!(err.to_string().contains("factor"), "got: {err}");
202    }
203}