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 tokio::time::Instant;
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 clone shares the deadline. Every layer of one throttle holds a clone of
27/// one `Hold`, so replacing or resizing a layer keeps it.
28#[derive(Clone)]
29pub struct Hold {
30    inner: Arc<HoldInner>,
31}
32
33struct HoldInner {
34    until: Mutex<Option<Instant>>,
35    ceiling: Option<Duration>,
36}
37
38impl Hold {
39    /// A hold with no ceiling: every [`extend`](Self::extend) is taken as
40    /// asked.
41    pub fn unbounded() -> Self {
42        Self::new(None)
43    }
44
45    /// A hold that clamps every [`extend`](Self::extend) to `ceiling`, so a
46    /// server asking for longer holds for `ceiling`.
47    pub fn with_ceiling(ceiling: Duration) -> Self {
48        Self::new(Some(ceiling))
49    }
50
51    fn new(ceiling: Option<Duration>) -> Self {
52        Self {
53            inner: Arc::new(HoldInner {
54                until: Mutex::new(None),
55                ceiling,
56            }),
57        }
58    }
59
60    /// Hold every request for `delay` from now, clamped to the ceiling.
61    ///
62    /// Extends a hold in force but never shortens one. A delay too large for
63    /// the clock holds for thirty years rather than panicking.
64    pub fn extend(&self, delay: Duration) {
65        let delay = self
66            .inner
67            .ceiling
68            .map_or(delay, |ceiling| delay.min(ceiling));
69        let now = Instant::now();
70        let until = now.checked_add(delay).unwrap_or(now + FAR_FUTURE);
71        let mut slot = self.lock();
72        if slot.is_none_or(|current| until > current) {
73            *slot = Some(until);
74        }
75    }
76
77    /// Wait out the hold in force, if any.
78    pub async fn wait(&self) {
79        loop {
80            // Read the deadline and release the guard before awaiting. Holding
81            // a `std::sync::MutexGuard` across an await makes the future
82            // `!Send`, which every throttle's `acquire` needs it to be.
83            let deadline = *self.lock();
84            let Some(deadline) = deadline else { return };
85            if deadline <= Instant::now() {
86                return;
87            }
88            // Loop rather than return after sleeping: a sibling's 429 can push
89            // the deadline out while we wait, and waking into a still-active
90            // block is how the storm restarts.
91            tokio::time::sleep_until(deadline).await;
92        }
93    }
94
95    /// Whether a hold is in force now.
96    pub fn is_held(&self) -> bool {
97        self.lock().is_some_and(|until| until > Instant::now())
98    }
99
100    /// A poison-tolerant lock on the deadline.
101    ///
102    /// A panic elsewhere must not turn the throttle into a permanent outage;
103    /// the worst a torn write can cost here is one early or late wakeup.
104    fn lock(&self) -> MutexGuard<'_, Option<Instant>> {
105        self.inner
106            .until
107            .lock()
108            .unwrap_or_else(|poisoned| poisoned.into_inner())
109    }
110}
111
112impl Default for Hold {
113    /// [`Hold::unbounded`].
114    fn default() -> Self {
115        Self::unbounded()
116    }
117}
118
119impl std::fmt::Debug for Hold {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        let remaining =
122            (*self.lock()).and_then(|until| until.checked_duration_since(Instant::now()));
123        f.debug_struct("Hold")
124            .field("remaining", &remaining)
125            .field("ceiling", &self.inner.ceiling)
126            .finish()
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[tokio::test(start_paused = true)]
135    async fn a_hold_never_shortens() {
136        let hold = Hold::unbounded();
137        hold.extend(Duration::from_secs(10));
138        hold.extend(Duration::from_secs(1));
139
140        let t = Instant::now();
141        hold.wait().await;
142        assert!(
143            t.elapsed() >= Duration::from_secs(10),
144            "the longer hold was cut to {:?}",
145            t.elapsed()
146        );
147    }
148
149    #[tokio::test(start_paused = true)]
150    async fn a_hold_extended_mid_wait_is_honoured_in_full() {
151        // One call to `wait`, so nothing after it can re-check: a throttle's
152        // `acquire` waits again after its buckets, which would hide a `wait`
153        // that slept only once.
154        let hold = Hold::unbounded();
155        hold.extend(Duration::from_secs(2));
156        let extender = {
157            let hold = hold.clone();
158            tokio::spawn(async move {
159                tokio::time::sleep(Duration::from_secs(1)).await;
160                hold.extend(Duration::from_secs(5));
161            })
162        };
163
164        let t = Instant::now();
165        hold.wait().await;
166        extender.await.unwrap();
167        assert!(
168            t.elapsed() >= Duration::from_secs(6),
169            "woke at {:?}, at the deadline it first read",
170            t.elapsed()
171        );
172    }
173
174    #[tokio::test(start_paused = true)]
175    async fn the_ceiling_clamps_a_longer_hold() {
176        const DAY: Duration = Duration::from_secs(86_400);
177        let hold = Hold::with_ceiling(3 * DAY);
178        hold.extend(10 * DAY);
179
180        let t = Instant::now();
181        hold.wait().await;
182        assert!(
183            t.elapsed() >= 3 * DAY && t.elapsed() < 10 * DAY,
184            "held {:?}, not the 3-day ceiling",
185            t.elapsed()
186        );
187    }
188
189    #[tokio::test(start_paused = true)]
190    async fn a_huge_delay_saturates_rather_than_panics() {
191        let hold = Hold::unbounded();
192        hold.extend(Duration::MAX);
193        assert!(
194            tokio::time::timeout(Duration::from_secs(86_400 * 365), hold.wait())
195                .await
196                .is_err(),
197            "a hold of Duration::MAX released within a year"
198        );
199    }
200}