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//! # Feature flags
65//!
66//! - `core` (off by default) adds `*_now(clock)` convenience methods on
67//!   [`Timeout`] and [`Deadline`] that read the time from a
68//!   `reliakit_core::Clock`. It pulls in `reliakit-core` (`no_std`, zero
69//!   third-party dependencies); the `now: u64` methods remain the primitive API.
70
71#![no_std]
72#![forbid(unsafe_code)]
73#![warn(missing_docs)]
74
75/// A reusable timeout budget that is not yet pinned to a timeline.
76///
77/// A `Timeout` is just a length (in your chosen monotonic unit). Configure it
78/// once and call [`start`](Self::start) per operation to get a [`Deadline`].
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
80pub struct Timeout {
81    budget: u64,
82}
83
84impl Timeout {
85    /// Creates a timeout with the given `budget` (its length).
86    pub const fn new(budget: u64) -> Self {
87        Self { budget }
88    }
89
90    /// The budget (length) of this timeout.
91    pub const fn budget(&self) -> u64 {
92        self.budget
93    }
94
95    /// Pins this timeout to the timeline, starting at `now`.
96    pub const fn start(&self, now: u64) -> Deadline {
97        Deadline::new(now, self.budget)
98    }
99}
100
101/// A time budget pinned to a monotonic timeline.
102///
103/// A `Deadline` is a `start` instant plus a `budget`; it expires at
104/// `start + budget`. It never reads the clock — pass `now` to the query
105/// methods. All arithmetic saturates, so a backwards-moving clock or an
106/// overflowing `start + budget` cannot panic.
107///
108/// A zero budget expires immediately at `start`. For the same reason,
109/// [`Deadline::default`] (`start` and `budget` both `0`) is already expired —
110/// it is not an "infinite" deadline.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
112pub struct Deadline {
113    start: u64,
114    budget: u64,
115}
116
117impl Deadline {
118    /// Creates a deadline that expires `budget` units after `start`.
119    pub const fn new(start: u64, budget: u64) -> Self {
120        Self { start, budget }
121    }
122
123    /// The start instant.
124    pub const fn start(&self) -> u64 {
125        self.start
126    }
127
128    /// The budget (the length of the deadline).
129    pub const fn budget(&self) -> u64 {
130        self.budget
131    }
132
133    /// The instant the deadline expires, i.e. `start + budget` (saturating).
134    pub const fn expiry(&self) -> u64 {
135        self.start.saturating_add(self.budget)
136    }
137
138    /// Time elapsed since `start` at `now`.
139    ///
140    /// Saturates to `0` when `now` is before `start`.
141    pub const fn elapsed(&self, now: u64) -> u64 {
142        now.saturating_sub(self.start)
143    }
144
145    /// Time left until expiry at `now`.
146    ///
147    /// Saturates to `0` once the deadline has expired.
148    pub const fn remaining(&self, now: u64) -> u64 {
149        self.expiry().saturating_sub(now)
150    }
151
152    /// Whether the deadline has expired at `now` (`now >= expiry`).
153    pub const fn is_expired(&self, now: u64) -> bool {
154        now >= self.expiry()
155    }
156
157    /// Returns the remaining time if the deadline is still live at `now`, or
158    /// `None` once it has expired.
159    pub const fn check(&self, now: u64) -> Option<u64> {
160        if self.is_expired(now) {
161            None
162        } else {
163            Some(self.remaining(now))
164        }
165    }
166
167    /// Whether an operation that needs `duration` units can finish before the
168    /// deadline at `now` (`remaining(now) >= duration`).
169    ///
170    /// A `duration` of `0` is always allowed, even once the deadline has
171    /// expired.
172    pub const fn allows(&self, now: u64, duration: u64) -> bool {
173        self.remaining(now) >= duration
174    }
175
176    /// Caps `duration` so it does not run past the deadline: the smaller of
177    /// `duration` and [`remaining`](Self::remaining) at `now`.
178    ///
179    /// Handy for bounding a backoff delay by the time left in the budget.
180    pub const fn clamp(&self, now: u64, duration: u64) -> u64 {
181        let left = self.remaining(now);
182        if duration < left {
183            duration
184        } else {
185            left
186        }
187    }
188}
189
190/// Convenience methods that read the current time from a
191/// [`Clock`](reliakit_core::Clock) instead of taking an explicit `now: u64`.
192///
193/// Available with the `core` feature. Each forwards to the matching `now`-taking
194/// method, which remains the primitive API.
195#[cfg(feature = "core")]
196impl Timeout {
197    /// Like [`start`](Self::start), reading the start instant from `clock`.
198    ///
199    /// ```
200    /// use reliakit_timeout::Timeout;
201    /// use reliakit_core::ManualClock;
202    ///
203    /// let clock = ManualClock::new(1_000);
204    /// let deadline = Timeout::new(30_000).start_now(&clock);
205    /// clock.advance(10_000);
206    /// assert_eq!(deadline.remaining_now(&clock), 20_000);
207    /// assert!(!deadline.is_expired_now(&clock));
208    /// ```
209    pub fn start_now<C: reliakit_core::Clock>(&self, clock: &C) -> Deadline {
210        self.start(clock.now())
211    }
212}
213
214/// Convenience methods that read the current time from a
215/// [`Clock`](reliakit_core::Clock) instead of taking an explicit `now: u64`.
216///
217/// Available with the `core` feature. Each forwards to the matching `now`-taking
218/// method, which remains the primitive API.
219#[cfg(feature = "core")]
220impl Deadline {
221    /// Like [`elapsed`](Self::elapsed), reading the time from `clock`.
222    pub fn elapsed_now<C: reliakit_core::Clock>(&self, clock: &C) -> u64 {
223        self.elapsed(clock.now())
224    }
225
226    /// Like [`remaining`](Self::remaining), reading the time from `clock`.
227    pub fn remaining_now<C: reliakit_core::Clock>(&self, clock: &C) -> u64 {
228        self.remaining(clock.now())
229    }
230
231    /// Like [`is_expired`](Self::is_expired), reading the time from `clock`.
232    pub fn is_expired_now<C: reliakit_core::Clock>(&self, clock: &C) -> bool {
233        self.is_expired(clock.now())
234    }
235
236    /// Like [`check`](Self::check), reading the time from `clock`.
237    pub fn check_now<C: reliakit_core::Clock>(&self, clock: &C) -> Option<u64> {
238        self.check(clock.now())
239    }
240
241    /// Like [`allows`](Self::allows), reading the time from `clock`.
242    pub fn allows_now<C: reliakit_core::Clock>(&self, clock: &C, duration: u64) -> bool {
243        self.allows(clock.now(), duration)
244    }
245
246    /// Like [`clamp`](Self::clamp), reading the time from `clock`.
247    pub fn clamp_now<C: reliakit_core::Clock>(&self, clock: &C, duration: u64) -> u64 {
248        self.clamp(clock.now(), duration)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn timeout_starts_a_deadline() {
258        let t = Timeout::new(100);
259        assert_eq!(t.budget(), 100);
260        let d = t.start(50);
261        assert_eq!(d, Deadline::new(50, 100));
262        assert_eq!(d.start(), 50);
263        assert_eq!(d.budget(), 100);
264        assert_eq!(d.expiry(), 150);
265    }
266
267    #[test]
268    fn remaining_and_elapsed_track_time() {
269        let d = Deadline::new(1_000, 500);
270        assert_eq!(d.elapsed(1_000), 0);
271        assert_eq!(d.remaining(1_000), 500);
272        assert_eq!(d.elapsed(1_200), 200);
273        assert_eq!(d.remaining(1_200), 300);
274        assert_eq!(d.elapsed(1_500), 500);
275        assert_eq!(d.remaining(1_500), 0);
276    }
277
278    #[test]
279    fn expiry_boundary_is_inclusive() {
280        let d = Deadline::new(0, 10);
281        assert!(!d.is_expired(9));
282        assert!(d.is_expired(10)); // exactly at expiry counts as expired
283        assert!(d.is_expired(11));
284        assert_eq!(d.check(9), Some(1));
285        assert_eq!(d.check(10), None);
286    }
287
288    #[test]
289    fn zero_budget_expires_immediately() {
290        let d = Deadline::new(42, 0);
291        assert_eq!(d.expiry(), 42);
292        assert!(d.is_expired(42));
293        assert_eq!(d.remaining(42), 0);
294        assert_eq!(d.check(42), None);
295        // Before the start instant it is not yet expired.
296        assert!(!d.is_expired(41));
297        assert_eq!(d.check(41), Some(1));
298    }
299
300    #[test]
301    fn backwards_clock_does_not_panic_or_underflow() {
302        let d = Deadline::new(1_000, 200);
303        // now < start: elapsed saturates to 0, remaining is the full budget.
304        assert_eq!(d.elapsed(0), 0);
305        assert_eq!(d.remaining(0), 1_200);
306        assert!(!d.is_expired(0));
307    }
308
309    #[test]
310    fn expiry_saturates_on_overflow() {
311        let d = Deadline::new(u64::MAX - 5, 100);
312        assert_eq!(d.expiry(), u64::MAX);
313        assert!(!d.is_expired(u64::MAX - 1));
314        assert!(d.is_expired(u64::MAX));
315        assert_eq!(d.remaining(u64::MAX - 10), 10);
316    }
317
318    #[test]
319    fn allows_checks_fit() {
320        let d = Deadline::new(0, 100);
321        assert!(d.allows(0, 100));
322        assert!(d.allows(0, 99));
323        assert!(!d.allows(0, 101));
324        assert!(d.allows(60, 40));
325        assert!(!d.allows(60, 41));
326        assert!(!d.allows(100, 1)); // already expired
327        assert!(d.allows(0, 0)); // zero duration always fits
328        assert!(d.allows(100, 0)); // ...even once expired
329    }
330
331    #[test]
332    fn clamp_caps_duration_by_remaining() {
333        let d = Deadline::new(0, 1_000);
334        assert_eq!(d.clamp(0, 800), 800); // 800 < 1000 remaining
335        assert_eq!(d.clamp(500, 800), 500); // only 500 left
336        assert_eq!(d.clamp(1_000, 800), 0); // expired -> no time
337        assert_eq!(d.clamp(0, 1_000), 1_000); // exactly the budget
338    }
339
340    #[test]
341    fn defaults_are_zero() {
342        assert_eq!(Timeout::default(), Timeout::new(0));
343        assert_eq!(Deadline::default(), Deadline::new(0, 0));
344        // A default deadline is already expired, not infinite.
345        assert!(Deadline::default().is_expired(0));
346        assert_eq!(Deadline::default().check(0), None);
347    }
348}
349
350#[cfg(all(test, feature = "core"))]
351mod core_tests {
352    use super::*;
353    use reliakit_core::ManualClock;
354
355    #[test]
356    fn now_methods_match_explicit_now() {
357        let clock = ManualClock::new(1_000);
358        let deadline = Timeout::new(500).start_now(&clock);
359        assert_eq!(deadline, Timeout::new(500).start(1_000));
360
361        clock.set(1_200);
362        assert_eq!(deadline.elapsed_now(&clock), deadline.elapsed(1_200));
363        assert_eq!(deadline.remaining_now(&clock), deadline.remaining(1_200));
364        assert_eq!(deadline.is_expired_now(&clock), deadline.is_expired(1_200));
365        assert_eq!(deadline.check_now(&clock), deadline.check(1_200));
366        assert_eq!(
367            deadline.allows_now(&clock, 200),
368            deadline.allows(1_200, 200)
369        );
370        assert_eq!(deadline.clamp_now(&clock, 400), deadline.clamp(1_200, 400));
371
372        clock.set(2_000); // past expiry
373        assert!(deadline.is_expired_now(&clock));
374        assert_eq!(deadline.check_now(&clock), None);
375    }
376}