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}