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}