Skip to main content

adaptive_timeout/
parse.rs

1//! Parser for duration-range strings into [`BackoffInterval`].
2//!
3//! Format: `<min>..<max>`
4//!
5//! Each duration is a number (integer or fractional) followed by a unit suffix.
6//! Spaces between the number and suffix are allowed.
7//!
8//! Unit designators are compatible with [jiff's friendly duration format][jiff],
9//! which is what restate uses for configuration parsing.
10//!
11//! [jiff]: https://docs.rs/jiff/latest/jiff/fmt/friendly/index.html
12//!
13//! # Examples
14//!
15//! ```
16//! use adaptive_timeout::BackoffInterval;
17//!
18//! let b: BackoffInterval = "10ms..1s".parse().unwrap();
19//! assert_eq!(b.min_ms.get(), 10);
20//! assert_eq!(b.max_ms.get(), 1_000);
21//!
22//! let b: BackoffInterval = "0.5s..1min".parse().unwrap();
23//! assert_eq!(b.min_ms.get(), 500);
24//! assert_eq!(b.max_ms.get(), 60_000);
25//! ```
26
27use std::fmt;
28use std::str::FromStr;
29
30use crate::config::{MillisNonZero, TimeoutConfig};
31
32/// Timeout floor and ceiling parsed from a duration-range string.
33///
34/// Format: `<min>..<max>` (e.g., `10ms..60s`).
35///
36/// Both bounds use [`MillisNonZero`] — they are always positive.
37///
38/// Unit designators are compatible with [jiff's friendly duration format][jiff],
39/// which is what restate uses for configuration parsing.
40///
41/// [jiff]: https://docs.rs/jiff/latest/jiff/fmt/friendly/index.html
42///
43/// # Parsing
44///
45/// ```
46/// use adaptive_timeout::BackoffInterval;
47///
48/// let b: BackoffInterval = "10ms..60s".parse().unwrap();
49/// assert_eq!(b.min_ms.get(), 10);
50/// assert_eq!(b.max_ms.get(), 60_000);
51///
52/// // Jiff-style compact designators work:
53/// let b: BackoffInterval = "250ms..1m".parse().unwrap();
54/// assert_eq!(b.min_ms.get(), 250);
55/// assert_eq!(b.max_ms.get(), 60_000);
56///
57/// // Verbose designators too:
58/// let b: BackoffInterval = "500 milliseconds..2 minutes".parse().unwrap();
59/// assert_eq!(b.min_ms.get(), 500);
60/// assert_eq!(b.max_ms.get(), 120_000);
61/// ```
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct BackoffInterval {
64    /// Floor for computed timeout in milliseconds. Default: 250ms.
65    pub min_ms: MillisNonZero,
66    /// Ceiling for computed timeout in milliseconds. Default: 60,000ms (1min).
67    pub max_ms: MillisNonZero,
68}
69
70impl Default for BackoffInterval {
71    fn default() -> Self {
72        Self {
73            min_ms: MillisNonZero::new(250).unwrap(),
74            max_ms: MillisNonZero::new(60_000).unwrap(),
75        }
76    }
77}
78
79impl fmt::Display for BackoffInterval {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        let min = format_ms(self.min_ms.get() as u64);
82        let max = format_ms(self.max_ms.get() as u64);
83        write!(f, "{min}..{max}")
84    }
85}
86
87/// Format milliseconds into the most natural unit.
88///
89/// Uses jiff's compact designators (`d`, `h`, `m`, `s`, `ms`) so that
90/// output is directly parseable by both this crate and jiff/restate.
91fn format_ms(ms: u64) -> String {
92    if ms == 0 {
93        return "0ms".to_string();
94    }
95    if ms.is_multiple_of(86_400_000) {
96        format!("{}d", ms / 86_400_000)
97    } else if ms.is_multiple_of(3_600_000) {
98        format!("{}h", ms / 3_600_000)
99    } else if ms.is_multiple_of(60_000) {
100        format!("{}m", ms / 60_000)
101    } else if ms.is_multiple_of(1_000) {
102        format!("{}s", ms / 1_000)
103    } else {
104        format!("{ms}ms")
105    }
106}
107
108/// Convert a [`BackoffInterval`] into a [`TimeoutConfig`].
109///
110/// Quantile and safety factor remain at defaults.
111impl From<BackoffInterval> for TimeoutConfig {
112    fn from(backoff: BackoffInterval) -> Self {
113        TimeoutConfig {
114            backoff,
115            ..TimeoutConfig::default()
116        }
117    }
118}
119
120// ---------------------------------------------------------------------------
121// Parsing
122// ---------------------------------------------------------------------------
123
124/// Errors that can occur when parsing a [`BackoffInterval`] string.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum ParseError {
127    /// The input string is empty.
128    Empty,
129    /// Missing the `..` range separator.
130    MissingRangeSeparator,
131    /// Failed to parse the minimum timeout duration.
132    InvalidMin(String),
133    /// Failed to parse the maximum timeout duration.
134    InvalidMax(String),
135    /// The minimum timeout is zero.
136    ZeroMin,
137    /// The max timeout is zero.
138    ZeroMax,
139    /// The minimum timeout exceeds `u32::MAX`.
140    MinOverflow(u64),
141    /// The maximum timeout exceeds `u32::MAX`.
142    MaxOverflow(u64),
143    /// `min > max`.
144    MinExceedsMax { min_ms: u64, max_ms: u64 },
145}
146
147impl fmt::Display for ParseError {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        match self {
150            Self::Empty => write!(f, "empty input"),
151            Self::MissingRangeSeparator => write!(f, "missing '..' range separator"),
152            Self::InvalidMin(s) => write!(f, "invalid min timeout: {s}"),
153            Self::InvalidMax(s) => write!(f, "invalid max timeout: {s}"),
154            Self::ZeroMin => write!(f, "min timeout must be > 0"),
155            Self::ZeroMax => write!(f, "max timeout must be > 0"),
156            Self::MinOverflow(v) => write!(f, "min timeout ({v}ms) exceeds u32::MAX"),
157            Self::MaxOverflow(v) => write!(f, "max timeout ({v}ms) exceeds u32::MAX"),
158            Self::MinExceedsMax { min_ms, max_ms } => {
159                write!(
160                    f,
161                    "min timeout ({min_ms}ms) exceeds max timeout ({max_ms}ms)"
162                )
163            }
164        }
165    }
166}
167
168impl std::error::Error for ParseError {}
169
170impl FromStr for BackoffInterval {
171    type Err = ParseError;
172
173    fn from_str(s: &str) -> Result<Self, Self::Err> {
174        let s = s.trim();
175        if s.is_empty() {
176            return Err(ParseError::Empty);
177        }
178
179        let (min_str, max_str) = s
180            .split_once("..")
181            .ok_or(ParseError::MissingRangeSeparator)?;
182
183        let min_raw =
184            parse_duration_ms(min_str.trim()).map_err(|e| ParseError::InvalidMin(e.to_string()))?;
185        let max_raw =
186            parse_duration_ms(max_str.trim()).map_err(|e| ParseError::InvalidMax(e.to_string()))?;
187
188        if min_raw == 0 {
189            return Err(ParseError::ZeroMin);
190        }
191        if max_raw == 0 {
192            return Err(ParseError::ZeroMax);
193        }
194        if min_raw > max_raw {
195            return Err(ParseError::MinExceedsMax {
196                min_ms: min_raw,
197                max_ms: max_raw,
198            });
199        }
200
201        let min_u32 = u32::try_from(min_raw).map_err(|_| ParseError::MinOverflow(min_raw))?;
202        let max_u32 = u32::try_from(max_raw).map_err(|_| ParseError::MaxOverflow(max_raw))?;
203
204        // Safety: we already checked > 0 above.
205        let min_ms = MillisNonZero::new(min_u32).unwrap();
206        let max_ms = MillisNonZero::new(max_u32).unwrap();
207
208        Ok(BackoffInterval { min_ms, max_ms })
209    }
210}
211
212// ---------------------------------------------------------------------------
213// Duration string parser
214// ---------------------------------------------------------------------------
215
216/// Errors from parsing a single duration string.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub(crate) enum DurationParseError {
219    Empty,
220    InvalidNumber(String),
221    UnknownUnit(String),
222    /// The parsed value truncates to zero milliseconds (e.g., `1ns`).
223    TruncatesToZero(String),
224}
225
226impl fmt::Display for DurationParseError {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        match self {
229            Self::Empty => write!(f, "empty duration string"),
230            Self::InvalidNumber(s) => write!(f, "invalid number: {s}"),
231            Self::UnknownUnit(s) => write!(f, "unknown unit: {s}"),
232            Self::TruncatesToZero(s) => {
233                write!(f, "duration '{s}' truncates to 0ms")
234            }
235        }
236    }
237}
238
239/// Parse a duration string like `10ms`, `1.5s`, `2min` into milliseconds.
240///
241/// Unit designators are compatible with [jiff's friendly duration format][jiff].
242/// All jiff unit labels (compact, short, and verbose) are accepted:
243///
244/// | Unit         | Accepted designators                                                           |
245/// |--------------|-------------------------------------------------------------------------------|
246/// | nanoseconds  | `ns`, `nsec`, `nsecs`, `nano`, `nanos`, `nanosecond`, `nanoseconds`            |
247/// | microseconds | `us`, `µs`/`μs`, `usec`, `usecs`, `micro`, `micros`, `microsecond`, `microseconds` |
248/// | milliseconds | `ms`, `msec`, `msecs`, `milli`, `millis`, `millisecond`, `milliseconds`        |
249/// | seconds      | `s`, `sec`, `secs`, `second`, `seconds`                                       |
250/// | minutes      | `m`, `min`, `mins`, `minute`, `minutes`                                       |
251/// | hours        | `h`, `hr`, `hrs`, `hour`, `hours`                                             |
252/// | days         | `d`, `day`, `days`                                                            |
253///
254/// [jiff]: https://docs.rs/jiff/latest/jiff/fmt/friendly/index.html
255///
256/// Spaces between the number and unit are allowed: `10 ms` is valid.
257/// Fractional values are supported: `0.5s` = 500ms.
258fn parse_duration_ms(s: &str) -> Result<u64, DurationParseError> {
259    let s = s.trim();
260    if s.is_empty() {
261        return Err(DurationParseError::Empty);
262    }
263
264    // Special case: bare "0"
265    if s == "0" {
266        return Ok(0);
267    }
268
269    // Find the boundary between number and unit.
270    let num_end = s
271        .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+')
272        .unwrap_or(s.len());
273
274    let num_str = s[..num_end].trim();
275    let unit_str = s[num_end..].trim();
276
277    if num_str.is_empty() {
278        return Err(DurationParseError::InvalidNumber(s.to_string()));
279    }
280    if unit_str.is_empty() {
281        return Err(DurationParseError::UnknownUnit(
282            "missing unit suffix".to_string(),
283        ));
284    }
285
286    let value: f64 = num_str
287        .parse()
288        .map_err(|_| DurationParseError::InvalidNumber(num_str.to_string()))?;
289
290    // Unit designators match jiff's friendly format grammar exactly.
291    // U+00B5 (µ MICRO SIGN) and U+03BC (μ GREEK SMALL LETTER MU) both accepted.
292    let factor_ns: f64 = match unit_str {
293        "nanoseconds" | "nanosecond" | "nanos" | "nano" | "nsecs" | "nsec" | "ns" => 1.0,
294        "microseconds" | "microsecond" | "micros" | "micro" | "usecs" | "usec" | "us"
295        | "\u{00B5}s" | "\u{00B5}secs" | "\u{00B5}sec" | "\u{03BC}s" | "\u{03BC}secs"
296        | "\u{03BC}sec" => 1_000.0,
297        "milliseconds" | "millisecond" | "millis" | "milli" | "msecs" | "msec" | "ms" => {
298            1_000_000.0
299        }
300        "seconds" | "second" | "secs" | "sec" | "s" => 1_000_000_000.0,
301        "minutes" | "minute" | "mins" | "min" | "m" => 60.0 * 1_000_000_000.0,
302        "hours" | "hour" | "hrs" | "hr" | "h" => 3_600.0 * 1_000_000_000.0,
303        "days" | "day" | "d" => 86_400.0 * 1_000_000_000.0,
304        _ => return Err(DurationParseError::UnknownUnit(unit_str.to_string())),
305    };
306
307    let total_ns = value * factor_ns;
308    let ms = (total_ns / 1_000_000.0).round() as u64;
309
310    // Reject durations that round to 0ms but had a non-zero input.
311    if ms == 0 && value != 0.0 {
312        return Err(DurationParseError::TruncatesToZero(s.to_string()));
313    }
314
315    Ok(ms)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    // -----------------------------------------------------------------------
323    // Duration parsing
324    // -----------------------------------------------------------------------
325
326    #[test]
327    fn parse_milliseconds() {
328        assert_eq!(parse_duration_ms("10ms").unwrap(), 10);
329        assert_eq!(parse_duration_ms("100ms").unwrap(), 100);
330        assert_eq!(parse_duration_ms("1ms").unwrap(), 1);
331        assert_eq!(parse_duration_ms("0ms").unwrap(), 0);
332    }
333
334    #[test]
335    fn parse_seconds() {
336        assert_eq!(parse_duration_ms("1s").unwrap(), 1_000);
337        assert_eq!(parse_duration_ms("10s").unwrap(), 10_000);
338        assert_eq!(parse_duration_ms("1sec").unwrap(), 1_000);
339        assert_eq!(parse_duration_ms("2secs").unwrap(), 2_000);
340        assert_eq!(parse_duration_ms("1second").unwrap(), 1_000);
341        assert_eq!(parse_duration_ms("3seconds").unwrap(), 3_000);
342    }
343
344    #[test]
345    fn parse_fractional() {
346        assert_eq!(parse_duration_ms("0.5s").unwrap(), 500);
347        assert_eq!(parse_duration_ms("1.5s").unwrap(), 1_500);
348        assert_eq!(parse_duration_ms("0.1s").unwrap(), 100);
349        assert_eq!(parse_duration_ms("2.5ms").unwrap(), 3); // rounds
350    }
351
352    #[test]
353    fn parse_minutes() {
354        assert_eq!(parse_duration_ms("1min").unwrap(), 60_000);
355        assert_eq!(parse_duration_ms("2mins").unwrap(), 120_000);
356        assert_eq!(parse_duration_ms("1m").unwrap(), 60_000);
357        assert_eq!(parse_duration_ms("1minute").unwrap(), 60_000);
358        assert_eq!(parse_duration_ms("3minutes").unwrap(), 180_000);
359    }
360
361    #[test]
362    fn parse_hours() {
363        assert_eq!(parse_duration_ms("1h").unwrap(), 3_600_000);
364        assert_eq!(parse_duration_ms("1hr").unwrap(), 3_600_000);
365        assert_eq!(parse_duration_ms("2hrs").unwrap(), 7_200_000);
366        assert_eq!(parse_duration_ms("1hour").unwrap(), 3_600_000);
367        assert_eq!(parse_duration_ms("3hours").unwrap(), 10_800_000);
368    }
369
370    #[test]
371    fn parse_days() {
372        assert_eq!(parse_duration_ms("1d").unwrap(), 86_400_000);
373        assert_eq!(parse_duration_ms("1day").unwrap(), 86_400_000);
374        assert_eq!(parse_duration_ms("2days").unwrap(), 172_800_000);
375    }
376
377    #[test]
378    fn parse_microseconds() {
379        assert_eq!(parse_duration_ms("1000us").unwrap(), 1);
380        assert_eq!(parse_duration_ms("500us").unwrap(), 1); // rounds to 1ms
381        assert_eq!(parse_duration_ms("1000\u{03BC}s").unwrap(), 1); // Greek mu
382        assert_eq!(parse_duration_ms("1000\u{00B5}s").unwrap(), 1); // Micro sign (jiff)
383        assert_eq!(parse_duration_ms("1000usec").unwrap(), 1);
384        assert_eq!(parse_duration_ms("1000usecs").unwrap(), 1);
385        assert_eq!(parse_duration_ms("1000micro").unwrap(), 1);
386        assert_eq!(parse_duration_ms("1000micros").unwrap(), 1);
387        assert_eq!(parse_duration_ms("1000microsecond").unwrap(), 1);
388        assert_eq!(parse_duration_ms("1000microseconds").unwrap(), 1);
389    }
390
391    #[test]
392    fn parse_nanoseconds() {
393        assert_eq!(parse_duration_ms("1000000ns").unwrap(), 1);
394        assert_eq!(parse_duration_ms("1000000nsec").unwrap(), 1);
395        assert_eq!(parse_duration_ms("1000000nsecs").unwrap(), 1);
396        assert_eq!(parse_duration_ms("1000000nano").unwrap(), 1);
397        assert_eq!(parse_duration_ms("1000000nanos").unwrap(), 1);
398        assert_eq!(parse_duration_ms("1000000nanosecond").unwrap(), 1);
399        assert_eq!(parse_duration_ms("1000000nanoseconds").unwrap(), 1);
400    }
401
402    #[test]
403    fn parse_millisecond_aliases() {
404        assert_eq!(parse_duration_ms("10msec").unwrap(), 10);
405        assert_eq!(parse_duration_ms("10msecs").unwrap(), 10);
406        assert_eq!(parse_duration_ms("10milli").unwrap(), 10);
407        assert_eq!(parse_duration_ms("10millis").unwrap(), 10);
408        assert_eq!(parse_duration_ms("10millisecond").unwrap(), 10);
409        assert_eq!(parse_duration_ms("10milliseconds").unwrap(), 10);
410    }
411
412    #[test]
413    fn parse_with_spaces() {
414        assert_eq!(parse_duration_ms("10 ms").unwrap(), 10);
415        assert_eq!(parse_duration_ms(" 1 s ").unwrap(), 1_000);
416    }
417
418    #[test]
419    fn parse_bare_zero() {
420        assert_eq!(parse_duration_ms("0").unwrap(), 0);
421    }
422
423    #[test]
424    fn parse_truncates_to_zero() {
425        assert!(matches!(
426            parse_duration_ms("1ns"),
427            Err(DurationParseError::TruncatesToZero(_))
428        ));
429    }
430
431    #[test]
432    fn parse_unknown_unit() {
433        assert!(matches!(
434            parse_duration_ms("10xyz"),
435            Err(DurationParseError::UnknownUnit(_))
436        ));
437    }
438
439    #[test]
440    fn parse_missing_unit() {
441        assert!(matches!(
442            parse_duration_ms("42"),
443            Err(DurationParseError::UnknownUnit(_))
444        ));
445    }
446
447    #[test]
448    fn parse_empty() {
449        assert!(matches!(
450            parse_duration_ms(""),
451            Err(DurationParseError::Empty)
452        ));
453    }
454
455    // -----------------------------------------------------------------------
456    // BackoffInterval parsing
457    // -----------------------------------------------------------------------
458
459    #[test]
460    fn parse_basic_range() {
461        let b: BackoffInterval = "10ms..1s".parse().unwrap();
462        assert_eq!(b.min_ms.get(), 10);
463        assert_eq!(b.max_ms.get(), 1_000);
464    }
465
466    #[test]
467    fn parse_fractional_range() {
468        let b: BackoffInterval = "0.5s..1.5s".parse().unwrap();
469        assert_eq!(b.min_ms.get(), 500);
470        assert_eq!(b.max_ms.get(), 1_500);
471    }
472
473    #[test]
474    fn parse_with_spaces_around() {
475        let b: BackoffInterval = "  10ms .. 1s  ".parse().unwrap();
476        assert_eq!(b.min_ms.get(), 10);
477        assert_eq!(b.max_ms.get(), 1_000);
478    }
479
480    #[test]
481    fn parse_same_min_max() {
482        let b: BackoffInterval = "100ms..100ms".parse().unwrap();
483        assert_eq!(b.min_ms.get(), 100);
484        assert_eq!(b.max_ms.get(), 100);
485    }
486
487    #[test]
488    fn parse_large_values() {
489        let b: BackoffInterval = "1h..3d".parse().unwrap();
490        assert_eq!(b.min_ms.get(), 3_600_000);
491        assert_eq!(b.max_ms.get(), 259_200_000);
492    }
493
494    #[test]
495    fn parse_mixed_units() {
496        let b: BackoffInterval = "100ms..1min".parse().unwrap();
497        assert_eq!(b.min_ms.get(), 100);
498        assert_eq!(b.max_ms.get(), 60_000);
499    }
500
501    #[test]
502    fn err_empty() {
503        assert_eq!(
504            "".parse::<BackoffInterval>().unwrap_err(),
505            ParseError::Empty
506        );
507    }
508
509    #[test]
510    fn err_missing_separator() {
511        assert_eq!(
512            "10ms".parse::<BackoffInterval>().unwrap_err(),
513            ParseError::MissingRangeSeparator
514        );
515    }
516
517    #[test]
518    fn err_zero_min() {
519        assert_eq!(
520            "0ms..1s".parse::<BackoffInterval>().unwrap_err(),
521            ParseError::ZeroMin
522        );
523    }
524
525    #[test]
526    fn err_min_exceeds_max() {
527        assert!(matches!(
528            "10s..1s".parse::<BackoffInterval>(),
529            Err(ParseError::MinExceedsMax { .. })
530        ));
531    }
532
533    #[test]
534    fn err_invalid_min() {
535        assert!(matches!(
536            "abc..1s".parse::<BackoffInterval>(),
537            Err(ParseError::InvalidMin(_))
538        ));
539    }
540
541    #[test]
542    fn err_invalid_max() {
543        assert!(matches!(
544            "10ms..abc".parse::<BackoffInterval>(),
545            Err(ParseError::InvalidMax(_))
546        ));
547    }
548
549    // -----------------------------------------------------------------------
550    // Display round-trip
551    // -----------------------------------------------------------------------
552
553    #[test]
554    fn display_basic() {
555        let b: BackoffInterval = "10ms..1min".parse().unwrap();
556        assert_eq!(b.to_string(), "10ms..1m");
557    }
558
559    #[test]
560    fn display_sub_second() {
561        let b: BackoffInterval = "500ms..1500ms".parse().unwrap();
562        assert_eq!(b.to_string(), "500ms..1500ms");
563    }
564
565    #[test]
566    fn display_round_trip() {
567        for original in &["10ms..60s", "250ms..1m", "1s..1h", "100ms..1d"] {
568            let b: BackoffInterval = original.parse().unwrap();
569            let displayed = b.to_string();
570            let reparsed: BackoffInterval = displayed.parse().unwrap();
571            assert_eq!(b, reparsed, "round-trip failed for {original}");
572        }
573    }
574
575    // -----------------------------------------------------------------------
576    // From<BackoffInterval> for TimeoutConfig
577    // -----------------------------------------------------------------------
578
579    #[test]
580    fn into_timeout_config() {
581        let b: BackoffInterval = "50ms..30s".parse().unwrap();
582        let cfg: TimeoutConfig = b.into();
583        assert_eq!(cfg.backoff.min_ms.get(), 50);
584        assert_eq!(cfg.backoff.max_ms.get(), 30_000);
585        // Defaults preserved:
586        assert_eq!(cfg.quantile, 0.9999);
587        assert_eq!(cfg.safety_factor, 2.0);
588    }
589}