Skip to main content

adaptive_timeout/
config.rs

1use std::num::NonZeroU32;
2use std::time::Duration;
3
4use crate::BackoffInterval;
5
6/// Milliseconds stored as a [`NonZeroU32`].
7///
8/// Compact (4-byte) representation for timeout durations that are always
9/// positive. Max representable value is ~49.7 days.
10pub type MillisNonZero = NonZeroU32;
11
12/// Creates a [`MillisNonZero`] from a `u32` literal at compile time.
13///
14/// # Panics
15///
16/// Panics if `ms == 0`.
17#[inline]
18pub(crate) const fn millis(ms: u32) -> MillisNonZero {
19    match NonZeroU32::new(ms) {
20        Some(v) => v,
21        None => panic!("millis value must be non-zero"),
22    }
23}
24
25/// HdrHistogram precision used internally. 2 significant digits gives ~1%
26/// accuracy, which is more than sufficient for latency tracking.
27pub(crate) const SIGNIFICANT_VALUE_DIGITS: u8 = 2;
28
29/// Configuration for [`LatencyTracker`](crate::LatencyTracker).
30///
31/// All duration-like fields use integer milliseconds for compactness.
32///
33/// # Example
34///
35/// ```
36/// use adaptive_timeout::TrackerConfig;
37///
38/// let config = TrackerConfig {
39///     min_samples: 10,
40///     ..TrackerConfig::default()
41/// };
42/// ```
43#[derive(Debug, Clone, Copy)]
44pub struct TrackerConfig {
45    /// Total sliding window duration in milliseconds. This is the full time
46    /// span the histogram remembers — not per sub-window. The window is
47    /// divided into `N` equal sub-windows of `window_ms / N` each.
48    ///
49    /// Default: 60,000 (60s).
50    pub window_ms: NonZeroU32,
51
52    /// Minimum samples before quantile estimates are valid. Below this,
53    /// queries return `None`. Default: 3.
54    pub min_samples: u32,
55
56    /// Maximum trackable latency in milliseconds. Values above this are
57    /// clamped. Default: 60,000 (60s).
58    pub max_trackable_latency_ms: u32,
59}
60
61impl TrackerConfig {
62    /// Returns the sliding window duration as a [`Duration`].
63    #[inline]
64    pub fn window(&self) -> Duration {
65        Duration::from_millis(self.window_ms.get() as u64)
66    }
67}
68
69impl Default for TrackerConfig {
70    fn default() -> Self {
71        Self {
72            window_ms: millis(60_000),
73            min_samples: 3,
74            max_trackable_latency_ms: 60_000,
75        }
76    }
77}
78
79/// Configuration for [`AdaptiveTimeout`](crate::AdaptiveTimeout).
80///
81/// The struct fits in 24 bytes.
82///
83/// # Example
84///
85/// ```
86/// use adaptive_timeout::TimeoutConfig;
87///
88/// let config = TimeoutConfig {
89///     quantile: 0.999,
90///     safety_factor: 3.0,
91///     ..TimeoutConfig::default()
92/// };
93/// ```
94#[derive(Debug, Clone, Copy)]
95pub struct TimeoutConfig {
96    /// Timeout floor and ceiling. Default: `250ms..1min`.
97    pub backoff: BackoffInterval,
98
99    /// Quantile of the latency distribution to use (e.g. 0.9999 for P99.99).
100    /// Default: 0.9999.
101    pub quantile: f64,
102
103    /// Multiplier applied to the quantile estimate. A factor of 2.0 means
104    /// the timeout is twice the observed quantile. Default: 2.0.
105    pub safety_factor: f64,
106}
107
108impl TimeoutConfig {
109    /// Returns the minimum timeout as a [`Duration`].
110    #[inline]
111    pub fn min_timeout(&self) -> Duration {
112        Duration::from_millis(self.backoff.min_ms.get() as u64)
113    }
114
115    /// Returns the maximum timeout as a [`Duration`].
116    #[inline]
117    pub fn max_timeout(&self) -> Duration {
118        Duration::from_millis(self.backoff.max_ms.get() as u64)
119    }
120}
121
122impl Default for TimeoutConfig {
123    fn default() -> Self {
124        Self {
125            backoff: BackoffInterval::default(),
126            quantile: 0.9999,
127            safety_factor: 2.0,
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use std::mem;
136
137    #[test]
138    fn timeout_config_is_compact() {
139        assert_eq!(mem::size_of::<TimeoutConfig>(), 24);
140    }
141
142    #[test]
143    fn tracker_config_is_compact() {
144        assert!(mem::size_of::<TrackerConfig>() <= 12);
145    }
146
147    #[test]
148    fn millis_helper() {
149        let m = millis(42);
150        assert_eq!(m.get(), 42);
151    }
152
153    #[test]
154    #[should_panic(expected = "non-zero")]
155    fn millis_zero_panics() {
156        let _ = millis(0);
157    }
158
159    #[test]
160    fn timeout_config_conversions() {
161        let cfg = TimeoutConfig::default();
162        assert_eq!(cfg.min_timeout(), Duration::from_millis(250));
163        assert_eq!(cfg.max_timeout(), Duration::from_millis(60_000));
164    }
165
166    #[test]
167    fn tracker_config_window_conversion() {
168        let cfg = TrackerConfig::default();
169        assert_eq!(cfg.window(), Duration::from_secs(60));
170    }
171
172    #[test]
173    fn timeout_config_from_str() {
174        let cfg: TimeoutConfig = "10ms..60s".parse().unwrap();
175        assert_eq!(cfg.backoff.min_ms.get(), 10);
176        assert_eq!(cfg.backoff.max_ms.get(), 60_000);
177        assert_eq!(cfg.quantile, 0.9999);
178        assert_eq!(cfg.safety_factor, 2.0);
179    }
180
181    #[test]
182    fn timeout_config_from_str_error() {
183        assert!("not-a-range".parse::<TimeoutConfig>().is_err());
184    }
185
186    #[cfg(feature = "serde")]
187    mod serde_tests {
188        use super::*;
189
190        #[test]
191        fn serialize_as_backoff_string() {
192            let cfg: TimeoutConfig = "10ms..60s".parse().unwrap();
193            let json = serde_json::to_string(&cfg).unwrap();
194            assert_eq!(json, r#""10ms..1m""#);
195        }
196
197        #[test]
198        fn deserialize_from_string() {
199            let cfg: serde_json::Result<TimeoutConfig> = serde_json::from_str(r#""250ms..1m""#);
200            let cfg = cfg.unwrap();
201            assert_eq!(cfg.backoff.min_ms.get(), 250);
202            assert_eq!(cfg.backoff.max_ms.get(), 60_000);
203            assert_eq!(cfg.quantile, 0.9999);
204            assert_eq!(cfg.safety_factor, 2.0);
205        }
206
207        #[test]
208        fn serde_round_trip() {
209            let original: TimeoutConfig = "100ms..30s".parse().unwrap();
210            let json = serde_json::to_string(&original).unwrap();
211            let restored: TimeoutConfig = serde_json::from_str(&json).unwrap();
212            assert_eq!(original.backoff, restored.backoff);
213            assert_eq!(original.quantile, restored.quantile);
214            assert_eq!(original.safety_factor, restored.safety_factor);
215        }
216
217        #[test]
218        fn deserialize_error_propagated() {
219            assert!(serde_json::from_str::<TimeoutConfig>(r#""bad""#).is_err());
220        }
221    }
222}