Skip to main content

polyoxide_core/
hold.rs

1//! [`Hold`]: what the server said, shared by every layer of a throttle.
2
3use std::sync::{Arc, Mutex, MutexGuard};
4use std::time::Duration;
5
6use polyoxide_rt::{Instant, Runtime};
7
8/// Thirty years: where a deadline that would overflow the clock lands instead.
9const FAR_FUTURE: Duration = Duration::from_secs(86_400 * 365 * 30);
10
11/// A deadline before which no request on a throttle may proceed.
12///
13/// A throttle's buckets model the quota a venue *publishes*; a hold is what
14/// the server actually just said. They disagree more often than the tables
15/// suggest — Cloudflare's `error code: 1015` is an IP-scoped block with its
16/// own window, and it answers 429 no matter how many tokens the buckets still
17/// hold. So a 429 holds every request sharing the throttle, not just the one
18/// that saw it.
19///
20/// A hold only ever extends: several concurrent requests typically see the
21/// same 429 within a few milliseconds of each other, and taking the most
22/// recent value would let whichever response carried the smallest delay
23/// release all of them early. [`wait`](Self::wait) re-reads the deadline
24/// after waking, so a hold extended mid-wait is honoured in full.
25///
26/// A hold keeps time on a [`Runtime`]: it reads its
27/// deadline off the runtime's clock and its waits sleep on the runtime.
28/// [`unbounded`](Self::unbounded) and [`with_ceiling`](Self::with_ceiling)
29/// take the target's default runtime, [`unbounded_on`](Self::unbounded_on) and
30/// [`with_ceiling_on`](Self::with_ceiling_on) the one given. Every layer that
31/// waits and shares the hold keeps time on the hold's runtime, so one throttle
32/// never mixes two clocks.
33///
34/// A clone shares the deadline. Every layer of one throttle holds a clone of
35/// one `Hold`, so replacing or resizing a layer keeps it.
36#[derive(Clone)]
37pub struct Hold {
38    inner: Arc<HoldInner>,
39}
40
41struct HoldInner {
42    until: Mutex<Option<Instant>>,
43    ceiling: Option<Duration>,
44    runtime: Arc<dyn Runtime>,
45}
46
47impl Hold {
48    /// A hold with no ceiling, on the target's default runtime: every
49    /// [`extend`](Self::extend) is taken as asked.
50    pub fn unbounded() -> Self {
51        Self::unbounded_on(polyoxide_rt::default_runtime())
52    }
53
54    /// As [`unbounded`](Self::unbounded), keeping time on `runtime`.
55    pub fn unbounded_on(runtime: Arc<dyn Runtime>) -> Self {
56        Self::new(None, runtime)
57    }
58
59    /// A hold that clamps every [`extend`](Self::extend) to `ceiling`, so a
60    /// server asking for longer holds for `ceiling`, on the target's default
61    /// runtime.
62    pub fn with_ceiling(ceiling: Duration) -> Self {
63        Self::with_ceiling_on(ceiling, polyoxide_rt::default_runtime())
64    }
65
66    /// As [`with_ceiling`](Self::with_ceiling), keeping time on `runtime`.
67    pub fn with_ceiling_on(ceiling: Duration, runtime: Arc<dyn Runtime>) -> Self {
68        Self::new(Some(ceiling), runtime)
69    }
70
71    fn new(ceiling: Option<Duration>, runtime: Arc<dyn Runtime>) -> Self {
72        Self {
73            inner: Arc::new(HoldInner {
74                until: Mutex::new(None),
75                ceiling,
76                runtime,
77            }),
78        }
79    }
80
81    /// The runtime this hold keeps time on, which every layer sharing the
82    /// hold keeps time on too.
83    pub fn runtime(&self) -> &Arc<dyn Runtime> {
84        &self.inner.runtime
85    }
86
87    /// Hold every request for `delay` from now, clamped to the ceiling.
88    ///
89    /// Extends a hold in force but never shortens one. A delay too large for
90    /// the clock holds for thirty years rather than panicking.
91    pub fn extend(&self, delay: Duration) {
92        let delay = self
93            .inner
94            .ceiling
95            .map_or(delay, |ceiling| delay.min(ceiling));
96        let now = self.inner.runtime.now();
97        let until = now.checked_add(delay).unwrap_or(now + FAR_FUTURE);
98        let mut slot = self.lock();
99        if slot.is_none_or(|current| until > current) {
100            *slot = Some(until);
101        }
102    }
103
104    /// Wait out the hold in force, if any.
105    pub async fn wait(&self) {
106        loop {
107            // Read the deadline and release the guard before awaiting. Holding
108            // a `std::sync::MutexGuard` across an await makes the future
109            // `!Send`, which every throttle's `acquire` needs it to be.
110            let deadline = *self.lock();
111            let Some(deadline) = deadline else { return };
112            if deadline <= self.inner.runtime.now() {
113                return;
114            }
115            // Loop rather than return after sleeping: a sibling's 429 can push
116            // the deadline out while we wait, and waking into a still-active
117            // block is how the storm restarts.
118            self.inner.runtime.sleep_until(deadline).await;
119        }
120    }
121
122    /// Whether a hold is in force now.
123    pub fn is_held(&self) -> bool {
124        self.lock()
125            .is_some_and(|until| until > self.inner.runtime.now())
126    }
127
128    /// A poison-tolerant lock on the deadline.
129    ///
130    /// A panic elsewhere must not turn the throttle into a permanent outage;
131    /// the worst a torn write can cost here is one early or late wakeup.
132    fn lock(&self) -> MutexGuard<'_, Option<Instant>> {
133        self.inner
134            .until
135            .lock()
136            .unwrap_or_else(|poisoned| poisoned.into_inner())
137    }
138}
139
140impl Default for Hold {
141    /// [`Hold::unbounded`].
142    fn default() -> Self {
143        Self::unbounded()
144    }
145}
146
147impl std::fmt::Debug for Hold {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        let remaining =
150            (*self.lock()).and_then(|until| until.checked_duration_since(self.inner.runtime.now()));
151        f.debug_struct("Hold")
152            .field("remaining", &remaining)
153            .field("ceiling", &self.inner.ceiling)
154            .finish()
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use tokio::time::Instant;
161
162    use super::*;
163
164    #[tokio::test(start_paused = true)]
165    async fn a_hold_never_shortens() {
166        let hold = Hold::unbounded();
167        hold.extend(Duration::from_secs(10));
168        hold.extend(Duration::from_secs(1));
169
170        let t = Instant::now();
171        hold.wait().await;
172        assert!(
173            t.elapsed() >= Duration::from_secs(10),
174            "the longer hold was cut to {:?}",
175            t.elapsed()
176        );
177    }
178
179    #[tokio::test(start_paused = true)]
180    async fn a_hold_extended_mid_wait_is_honoured_in_full() {
181        // One call to `wait`, so nothing after it can re-check: a throttle's
182        // `acquire` waits again after its buckets, which would hide a `wait`
183        // that slept only once.
184        let hold = Hold::unbounded();
185        hold.extend(Duration::from_secs(2));
186        let extender = {
187            let hold = hold.clone();
188            tokio::spawn(async move {
189                tokio::time::sleep(Duration::from_secs(1)).await;
190                hold.extend(Duration::from_secs(5));
191            })
192        };
193
194        let t = Instant::now();
195        hold.wait().await;
196        extender.await.unwrap();
197        assert!(
198            t.elapsed() >= Duration::from_secs(6),
199            "woke at {:?}, at the deadline it first read",
200            t.elapsed()
201        );
202    }
203
204    #[tokio::test(start_paused = true)]
205    async fn the_ceiling_clamps_a_longer_hold() {
206        const DAY: Duration = Duration::from_secs(86_400);
207        let hold = Hold::with_ceiling(3 * DAY);
208        hold.extend(10 * DAY);
209
210        let t = Instant::now();
211        hold.wait().await;
212        assert!(
213            t.elapsed() >= 3 * DAY && t.elapsed() < 10 * DAY,
214            "held {:?}, not the 3-day ceiling",
215            t.elapsed()
216        );
217    }
218
219    #[tokio::test(start_paused = true)]
220    async fn a_huge_delay_saturates_rather_than_panics() {
221        let hold = Hold::unbounded();
222        hold.extend(Duration::MAX);
223        assert!(
224            tokio::time::timeout(Duration::from_secs(86_400 * 365), hold.wait())
225                .await
226                .is_err(),
227            "a hold of Duration::MAX released within a year"
228        );
229    }
230}