Skip to main content

trillium_grpc/
timeout.rs

1//! `grpc-timeout` header codec.
2//!
3//! The wire format is `<positive_integer><unit>` where unit is one of
4//! `H`, `M`, `S`, `m`, `u`, `n` (hour, minute, second, millisecond,
5//! microsecond, nanosecond). The integer is at most 8 ASCII digits.
6//!
7//! When formatting a `Duration` we pick the smallest unit that fits in 8
8//! digits and round *up*, so the wire value is always ≥ the requested
9//! duration — we never advertise a tighter deadline than asked.
10
11use std::time::Duration;
12
13const MAX_VALUE: u128 = 99_999_999;
14
15/// (unit_nanos, suffix) ordered smallest → largest. The first entry whose
16/// ceil(nanos / unit_nanos) fits in 8 digits is chosen.
17const UNITS: &[(u128, char)] = &[
18    (1, 'n'),
19    (1_000, 'u'),
20    (1_000_000, 'm'),
21    (1_000_000_000, 'S'),
22    (60 * 1_000_000_000, 'M'),
23    (3600 * 1_000_000_000, 'H'),
24];
25
26/// Format a [`Duration`] as a `grpc-timeout` header value.
27///
28/// Picks the smallest unit such that the rounded-up integer value fits in
29/// 8 digits. Durations longer than 99,999,999 hours saturate to that.
30pub fn format_grpc_timeout(d: Duration) -> String {
31    let nanos = d.as_nanos();
32    for &(unit_nanos, suffix) in UNITS {
33        let value = nanos.div_ceil(unit_nanos);
34        if value <= MAX_VALUE {
35            return format!("{value}{suffix}");
36        }
37    }
38    format!("{MAX_VALUE}H")
39}
40
41/// Parse a `grpc-timeout` header value into a [`Duration`]. Returns `None`
42/// for malformed input (empty, missing/unknown unit, non-digit body, or
43/// arithmetic overflow).
44pub fn parse_grpc_timeout(s: &str) -> Option<Duration> {
45    let bytes = s.as_bytes();
46    let (&suffix, digits) = bytes.split_last()?;
47    if digits.is_empty() || !digits.iter().all(u8::is_ascii_digit) {
48        return None;
49    }
50    let value: u64 = std::str::from_utf8(digits).ok()?.parse().ok()?;
51    let unit_nanos: u64 = match suffix {
52        b'n' => 1,
53        b'u' => 1_000,
54        b'm' => 1_000_000,
55        b'S' => 1_000_000_000,
56        b'M' => 60 * 1_000_000_000,
57        b'H' => 3600 * 1_000_000_000,
58        _ => return None,
59    };
60    // Use u128 to avoid overflow at the hours-times-99_999_999 extreme,
61    // then split into (secs, subsec_nanos) for Duration::new.
62    let total_nanos = (value as u128).checked_mul(unit_nanos as u128)?;
63    let secs = u64::try_from(total_nanos / 1_000_000_000).ok()?;
64    let nanos = (total_nanos % 1_000_000_000) as u32;
65    Some(Duration::new(secs, nanos))
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn format_picks_smallest_fitting_unit() {
74        assert_eq!(format_grpc_timeout(Duration::from_nanos(500)), "500n");
75        assert_eq!(format_grpc_timeout(Duration::from_micros(500)), "500000n");
76        // 500ms = 500_000_000n, doesn't fit (9 digits) → step up to micros.
77        assert_eq!(format_grpc_timeout(Duration::from_millis(500)), "500000u");
78        // 5s = 5_000_000us, fits in micros (7 digits).
79        assert_eq!(format_grpc_timeout(Duration::from_secs(5)), "5000000u");
80        // 5min = 5_000_000_000us doesn't fit, 5_000_000ms doesn't fit (10
81        // digits), 5_000ms doesn't... wait: 300_000ms fits → "300000m".
82        assert_eq!(format_grpc_timeout(Duration::from_secs(300)), "300000m");
83        // 1 hour = 3_600_000ms, fits as ms.
84        assert_eq!(format_grpc_timeout(Duration::from_secs(3600)), "3600000m");
85        // 1 day = 86_400s, fits as seconds.
86        assert_eq!(
87            format_grpc_timeout(Duration::from_secs(86_400)),
88            "86400000m"
89        );
90    }
91
92    #[test]
93    fn format_rounds_up() {
94        // 1500ns → 2us (round up, never under-promise).
95        assert_eq!(format_grpc_timeout(Duration::from_nanos(1500)), "1500n");
96        // Construct a duration that doesn't fit in nanos and isn't a
97        // whole number of micros: 99_999_999_500 ns = 99_999_999.5 us.
98        // Doesn't fit as nanos (12 digits). As micros: ceil = 100_000_000,
99        // doesn't fit. As ms: ceil(99_999_999_500 / 1_000_000) = 100_000.
100        assert_eq!(
101            format_grpc_timeout(Duration::from_nanos(99_999_999_500)),
102            "100000m"
103        );
104    }
105
106    #[test]
107    fn format_zero() {
108        assert_eq!(format_grpc_timeout(Duration::ZERO), "0n");
109    }
110
111    #[test]
112    fn format_saturates_at_max_hours() {
113        // u64::MAX nanos ≈ 213 years; well under 99_999_999 hours
114        // (~11_400 years). Construct something larger via from_secs:
115        let huge = Duration::from_secs(u64::MAX); // ~5.8e11 years
116        assert_eq!(format_grpc_timeout(huge), "99999999H");
117    }
118
119    #[test]
120    fn parse_each_unit() {
121        assert_eq!(parse_grpc_timeout("500n"), Some(Duration::from_nanos(500)));
122        assert_eq!(parse_grpc_timeout("500u"), Some(Duration::from_micros(500)));
123        assert_eq!(parse_grpc_timeout("500m"), Some(Duration::from_millis(500)));
124        assert_eq!(parse_grpc_timeout("5S"), Some(Duration::from_secs(5)));
125        assert_eq!(parse_grpc_timeout("2M"), Some(Duration::from_secs(120)));
126        assert_eq!(parse_grpc_timeout("1H"), Some(Duration::from_secs(3600)));
127    }
128
129    #[test]
130    fn parse_extreme_value() {
131        // 99_999_999H = 99_999_999 * 3600 seconds. Should parse cleanly.
132        let d = parse_grpc_timeout("99999999H").unwrap();
133        assert_eq!(d.as_secs(), 99_999_999u64 * 3600);
134    }
135
136    #[test]
137    fn parse_rejects_malformed() {
138        assert_eq!(parse_grpc_timeout(""), None);
139        assert_eq!(parse_grpc_timeout("S"), None); // no digits
140        assert_eq!(parse_grpc_timeout("5"), None); // no unit
141        assert_eq!(parse_grpc_timeout("5s"), None); // lowercase s is not a unit
142        assert_eq!(parse_grpc_timeout("5x"), None); // bad unit
143        assert_eq!(parse_grpc_timeout("-1S"), None); // sign not allowed
144        assert_eq!(parse_grpc_timeout("1.5S"), None); // not an integer
145        assert_eq!(parse_grpc_timeout(" 5S"), None); // whitespace not allowed
146    }
147
148    #[test]
149    fn parse_zero_is_valid() {
150        assert_eq!(parse_grpc_timeout("0n"), Some(Duration::ZERO));
151        assert_eq!(parse_grpc_timeout("0S"), Some(Duration::ZERO));
152    }
153
154    #[test]
155    fn roundtrip_typical_durations() {
156        for d in [
157            Duration::from_millis(1),
158            Duration::from_millis(100),
159            Duration::from_secs(1),
160            Duration::from_secs(30),
161            Duration::from_secs(300),
162        ] {
163            let formatted = format_grpc_timeout(d);
164            let parsed = parse_grpc_timeout(&formatted).unwrap();
165            assert_eq!(parsed, d, "roundtrip failed for {d:?} → {formatted:?}");
166        }
167    }
168}