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}