Skip to main content

reliakit_timeout/
lib.rs

1//! Clock-agnostic deadlines and timeouts.
2//!
3//! `reliakit-timeout` answers one question: *has my time budget run out, and how
4//! much is left?* It does not read the clock, sleep, or spawn anything — you
5//! capture a start instant and a budget, then pass `now` to the query methods.
6//! That makes it usable from sync code, any async runtime, and `no_std` /
7//! embedded contexts, with deterministic tests.
8//!
9//! Time is a plain `u64` in any monotonic unit you choose (milliseconds is
10//! typical), matching [`reliakit-circuit`] and [`reliakit-ratelimit`]. All
11//! arithmetic saturates, so no method panics — not on overflow, and not on a
12//! clock that moves backwards.
13//!
14//! Two small types:
15//!
16//! - [`Timeout`] is a reusable budget that is not yet pinned to a timeline.
17//!   Configure it once, then call [`Timeout::start`] per operation.
18//! - [`Deadline`] is a budget pinned to a start instant. Query it with
19//!   [`remaining`](Deadline::remaining), [`is_expired`](Deadline::is_expired),
20//!   [`check`](Deadline::check), and friends.
21//!
22//! # Example
23//!
24//! ```
25//! use reliakit_timeout::{Deadline, Timeout};
26//!
27//! // A 30s budget (here in milliseconds), pinned to the start of the operation.
28//! let policy = Timeout::new(30_000);
29//! let deadline = policy.start(1_000); // started at t = 1_000
30//!
31//! assert_eq!(deadline.remaining(1_000), 30_000);
32//! assert_eq!(deadline.remaining(21_000), 10_000);
33//! assert!(!deadline.is_expired(30_999));
34//! assert!(deadline.is_expired(31_000)); // expiry is inclusive
35//!
36//! // Not yet expired -> Some(remaining); expired -> None.
37//! assert_eq!(deadline.check(21_000), Some(10_000));
38//! assert_eq!(deadline.check(40_000), None);
39//! ```
40//!
41//! # Composing with backoff
42//!
43//! Use [`Deadline::clamp`] to keep a retry delay from running past the budget,
44//! and [`Deadline::is_expired`] to stop retrying:
45//!
46//! ```
47//! use reliakit_timeout::Deadline;
48//!
49//! let deadline = Deadline::new(0, 1_000);
50//! let proposed_backoff = 800; // ms the backoff policy wants to wait
51//!
52//! let now = 500;
53//! if deadline.is_expired(now) {
54//!     // give up
55//! } else {
56//!     let wait = deadline.clamp(now, proposed_backoff); // min(800, 500 left) = 500
57//!     assert_eq!(wait, 500);
58//! }
59//! ```
60//!
61//! [`reliakit-circuit`]: https://docs.rs/reliakit-circuit
62//! [`reliakit-ratelimit`]: https://docs.rs/reliakit-ratelimit
63
64#![no_std]
65#![forbid(unsafe_code)]
66#![warn(missing_docs)]
67
68/// A reusable timeout budget that is not yet pinned to a timeline.
69///
70/// A `Timeout` is just a length (in your chosen monotonic unit). Configure it
71/// once and call [`start`](Self::start) per operation to get a [`Deadline`].
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
73pub struct Timeout {
74    budget: u64,
75}
76
77impl Timeout {
78    /// Creates a timeout with the given `budget` (its length).
79    pub const fn new(budget: u64) -> Self {
80        Self { budget }
81    }
82
83    /// The budget (length) of this timeout.
84    pub const fn budget(&self) -> u64 {
85        self.budget
86    }
87
88    /// Pins this timeout to the timeline, starting at `now`.
89    pub const fn start(&self, now: u64) -> Deadline {
90        Deadline::new(now, self.budget)
91    }
92}
93
94/// A time budget pinned to a monotonic timeline.
95///
96/// A `Deadline` is a `start` instant plus a `budget`; it expires at
97/// `start + budget`. It never reads the clock — pass `now` to the query
98/// methods. All arithmetic saturates, so a backwards-moving clock or an
99/// overflowing `start + budget` cannot panic.
100///
101/// A zero budget expires immediately at `start`. For the same reason,
102/// [`Deadline::default`] (`start` and `budget` both `0`) is already expired —
103/// it is not an "infinite" deadline.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
105pub struct Deadline {
106    start: u64,
107    budget: u64,
108}
109
110impl Deadline {
111    /// Creates a deadline that expires `budget` units after `start`.
112    pub const fn new(start: u64, budget: u64) -> Self {
113        Self { start, budget }
114    }
115
116    /// The start instant.
117    pub const fn start(&self) -> u64 {
118        self.start
119    }
120
121    /// The budget (the length of the deadline).
122    pub const fn budget(&self) -> u64 {
123        self.budget
124    }
125
126    /// The instant the deadline expires, i.e. `start + budget` (saturating).
127    pub const fn expiry(&self) -> u64 {
128        self.start.saturating_add(self.budget)
129    }
130
131    /// Time elapsed since `start` at `now`.
132    ///
133    /// Saturates to `0` when `now` is before `start`.
134    pub const fn elapsed(&self, now: u64) -> u64 {
135        now.saturating_sub(self.start)
136    }
137
138    /// Time left until expiry at `now`.
139    ///
140    /// Saturates to `0` once the deadline has expired.
141    pub const fn remaining(&self, now: u64) -> u64 {
142        self.expiry().saturating_sub(now)
143    }
144
145    /// Whether the deadline has expired at `now` (`now >= expiry`).
146    pub const fn is_expired(&self, now: u64) -> bool {
147        now >= self.expiry()
148    }
149
150    /// Returns the remaining time if the deadline is still live at `now`, or
151    /// `None` once it has expired.
152    pub const fn check(&self, now: u64) -> Option<u64> {
153        if self.is_expired(now) {
154            None
155        } else {
156            Some(self.remaining(now))
157        }
158    }
159
160    /// Whether an operation that needs `duration` units can finish before the
161    /// deadline at `now` (`remaining(now) >= duration`).
162    ///
163    /// A `duration` of `0` is always allowed, even once the deadline has
164    /// expired.
165    pub const fn allows(&self, now: u64, duration: u64) -> bool {
166        self.remaining(now) >= duration
167    }
168
169    /// Caps `duration` so it does not run past the deadline: the smaller of
170    /// `duration` and [`remaining`](Self::remaining) at `now`.
171    ///
172    /// Handy for bounding a backoff delay by the time left in the budget.
173    pub const fn clamp(&self, now: u64, duration: u64) -> u64 {
174        let left = self.remaining(now);
175        if duration < left {
176            duration
177        } else {
178            left
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn timeout_starts_a_deadline() {
189        let t = Timeout::new(100);
190        assert_eq!(t.budget(), 100);
191        let d = t.start(50);
192        assert_eq!(d, Deadline::new(50, 100));
193        assert_eq!(d.start(), 50);
194        assert_eq!(d.budget(), 100);
195        assert_eq!(d.expiry(), 150);
196    }
197
198    #[test]
199    fn remaining_and_elapsed_track_time() {
200        let d = Deadline::new(1_000, 500);
201        assert_eq!(d.elapsed(1_000), 0);
202        assert_eq!(d.remaining(1_000), 500);
203        assert_eq!(d.elapsed(1_200), 200);
204        assert_eq!(d.remaining(1_200), 300);
205        assert_eq!(d.elapsed(1_500), 500);
206        assert_eq!(d.remaining(1_500), 0);
207    }
208
209    #[test]
210    fn expiry_boundary_is_inclusive() {
211        let d = Deadline::new(0, 10);
212        assert!(!d.is_expired(9));
213        assert!(d.is_expired(10)); // exactly at expiry counts as expired
214        assert!(d.is_expired(11));
215        assert_eq!(d.check(9), Some(1));
216        assert_eq!(d.check(10), None);
217    }
218
219    #[test]
220    fn zero_budget_expires_immediately() {
221        let d = Deadline::new(42, 0);
222        assert_eq!(d.expiry(), 42);
223        assert!(d.is_expired(42));
224        assert_eq!(d.remaining(42), 0);
225        assert_eq!(d.check(42), None);
226        // Before the start instant it is not yet expired.
227        assert!(!d.is_expired(41));
228        assert_eq!(d.check(41), Some(1));
229    }
230
231    #[test]
232    fn backwards_clock_does_not_panic_or_underflow() {
233        let d = Deadline::new(1_000, 200);
234        // now < start: elapsed saturates to 0, remaining is the full budget.
235        assert_eq!(d.elapsed(0), 0);
236        assert_eq!(d.remaining(0), 1_200);
237        assert!(!d.is_expired(0));
238    }
239
240    #[test]
241    fn expiry_saturates_on_overflow() {
242        let d = Deadline::new(u64::MAX - 5, 100);
243        assert_eq!(d.expiry(), u64::MAX);
244        assert!(!d.is_expired(u64::MAX - 1));
245        assert!(d.is_expired(u64::MAX));
246        assert_eq!(d.remaining(u64::MAX - 10), 10);
247    }
248
249    #[test]
250    fn allows_checks_fit() {
251        let d = Deadline::new(0, 100);
252        assert!(d.allows(0, 100));
253        assert!(d.allows(0, 99));
254        assert!(!d.allows(0, 101));
255        assert!(d.allows(60, 40));
256        assert!(!d.allows(60, 41));
257        assert!(!d.allows(100, 1)); // already expired
258        assert!(d.allows(0, 0)); // zero duration always fits
259        assert!(d.allows(100, 0)); // ...even once expired
260    }
261
262    #[test]
263    fn clamp_caps_duration_by_remaining() {
264        let d = Deadline::new(0, 1_000);
265        assert_eq!(d.clamp(0, 800), 800); // 800 < 1000 remaining
266        assert_eq!(d.clamp(500, 800), 500); // only 500 left
267        assert_eq!(d.clamp(1_000, 800), 0); // expired -> no time
268        assert_eq!(d.clamp(0, 1_000), 1_000); // exactly the budget
269    }
270
271    #[test]
272    fn defaults_are_zero() {
273        assert_eq!(Timeout::default(), Timeout::new(0));
274        assert_eq!(Deadline::default(), Deadline::new(0, 0));
275        // A default deadline is already expired, not infinite.
276        assert!(Deadline::default().is_expired(0));
277        assert_eq!(Deadline::default().check(0), None);
278    }
279}