Skip to main content

rss_cli/
ratelimit.rs

1//! Per-host request gate: shared, adaptive rate limiting for concurrent fetches.
2//!
3//! See [ADR-0016](../docs/adr/0016-per-host-request-gate.md). The gate lives inside a
4//! *reused* [`crate::fetch::HttpClient`] so concurrent MCP tool calls (and a CLI batch)
5//! coordinate their pacing toward a single host — preventing the self-inflicted `429` burst
6//! that a fresh-client-per-call fundamentally cannot. It is **reactive, not proactive**: it
7//! honors a server `Retry-After` where present and, for hosts that send none, applies a
8//! bounded escalating cooldown learned from consecutive throttles.
9//!
10//! This is complementary to the per-request retry of
11//! [ADR-0015](../docs/adr/0015-bounded-retry-on-transient-429-403.md): that reacts *within*
12//! one request; this schedules *across* concurrent requests. The two waits are one budget —
13//! a request holds its host permit through its own retry, so it never waits on the cooldown
14//! it just set.
15
16use std::collections::HashMap;
17use std::sync::Arc;
18use std::sync::Mutex;
19use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
20use std::time::Duration;
21
22use chrono::Utc;
23use tokio::sync::{OwnedSemaphorePermit, Semaphore};
24
25use crate::error::RssError;
26
27/// Max concurrent in-flight requests to a single authority (`host:port`). `1` serializes the
28/// many feeds that share a host while distinct hosts stay fully parallel (each has its own
29/// gate). Override with `RSS_HOST_CONCURRENCY`.
30const HOST_MAX_CONCURRENCY: usize = 1;
31
32/// First cooldown applied when a host `429`/`403`s **without** a `Retry-After` header; also
33/// the base for bounded escalation on consecutive throttles.
34const HOST_BASE_COOLDOWN: Duration = Duration::from_secs(2);
35
36/// Hard ceiling on any single cooldown (an honored `Retry-After` or an escalated default).
37/// Override with `RSS_MAX_COOLDOWN_SECS`.
38const HOST_MAX_COOLDOWN: Duration = Duration::from_secs(60);
39
40/// Inter-request spacing enforced while a host is "warm" (recently throttled). Costs the
41/// happy path nothing: a host that has never throttled is never warm.
42const STICKY_SPACING: Duration = Duration::from_secs(1);
43
44/// How long a host stays "warm" (sticky spacing active) after its most recent throttle.
45const WARM_WINDOW: Duration = Duration::from_secs(120);
46
47/// Max **cooldown/spacing** wait a request will absorb before failing fast with
48/// [`RssError::RateLimited`] (ADR-0016). This bounds the pacing sleep against `next_allowed`,
49/// **not** the time spent contending for the per-host permit — under `cap = 1` a sibling can
50/// still block on `acquire_owned` for as long as the in-flight holder runs, which is
51/// separately bounded by the reqwest request timeout. Override with `RSS_MAX_GATE_WAIT_SECS`.
52const MAX_GATE_WAIT: Duration = Duration::from_secs(60);
53
54/// Current wall-clock as epoch milliseconds (matches the crate's chrono-based clock).
55fn now_ms() -> i64 {
56    Utc::now().timestamp_millis()
57}
58
59/// Derive the gate key from a URL: lowercased `host:port` (with the scheme's default port
60/// filled in), e.g. `example.com:443`. A URL that fails to parse becomes its own bucket.
61fn authority_of(url: &str) -> String {
62    match url::Url::parse(url) {
63        Ok(u) => {
64            let host = u.host_str().unwrap_or_default().to_ascii_lowercase();
65            let port = u.port_or_known_default().unwrap_or(0);
66            format!("{host}:{port}")
67        }
68        Err(_) => url.to_string(),
69    }
70}
71
72/// Per-authority state. All timing fields are epoch-ms in atomics so they are read/updated
73/// lock-free; the only lock in the gate is the brief one around the slot map.
74struct HostSlot {
75    /// Concurrency cap for this host. `Arc` so `acquire_owned` yields a `'static` permit.
76    sem: Arc<Semaphore>,
77    /// Earliest epoch-ms at which the next request to this host may be *sent*.
78    next_allowed_ms: AtomicI64,
79    /// Epoch-ms until which the host is considered recently-throttled (sticky spacing on).
80    warm_until_ms: AtomicI64,
81    /// Consecutive throttles with no intervening success — drives cooldown escalation.
82    consecutive_throttles: AtomicU32,
83}
84
85impl HostSlot {
86    fn new(per_host: usize) -> Self {
87        Self {
88            sem: Arc::new(Semaphore::new(per_host.max(1))),
89            next_allowed_ms: AtomicI64::new(0),
90            warm_until_ms: AtomicI64::new(0),
91            consecutive_throttles: AtomicU32::new(0),
92        }
93    }
94}
95
96/// The shared per-host gate. Cheap to clone-share via `Arc` (as it is inside `HttpClient`).
97pub struct HostGate {
98    slots: Mutex<HashMap<String, Arc<HostSlot>>>,
99    per_host: usize,
100    base_cooldown: Duration,
101    max_cooldown: Duration,
102    sticky_spacing: Duration,
103    warm_window: Duration,
104    max_gate_wait: Duration,
105}
106
107impl Default for HostGate {
108    fn default() -> Self {
109        Self {
110            slots: Mutex::new(HashMap::new()),
111            per_host: HOST_MAX_CONCURRENCY,
112            base_cooldown: HOST_BASE_COOLDOWN,
113            max_cooldown: HOST_MAX_COOLDOWN,
114            sticky_spacing: STICKY_SPACING,
115            warm_window: WARM_WINDOW,
116            max_gate_wait: MAX_GATE_WAIT,
117        }
118    }
119}
120
121impl HostGate {
122    /// Build a gate, letting environment variables override the tunable defaults (ADR-0016).
123    pub fn from_env() -> Self {
124        let mut gate = Self::default();
125        if let Some(n) = env_parse::<usize>("RSS_HOST_CONCURRENCY") {
126            gate.per_host = n.max(1);
127        }
128        if let Some(secs) = env_parse::<u64>("RSS_MAX_COOLDOWN_SECS") {
129            gate.max_cooldown = Duration::from_secs(secs);
130        }
131        if let Some(secs) = env_parse::<u64>("RSS_MAX_GATE_WAIT_SECS") {
132            gate.max_gate_wait = Duration::from_secs(secs);
133        }
134        gate
135    }
136
137    /// Get (or create) the slot for an authority. Holds the map lock only for the O(1)
138    /// insert-and-clone — **never across an `.await`** (the one non-negotiable rule).
139    fn slot_for(&self, authority: &str) -> Arc<HostSlot> {
140        let mut slots = self.slots.lock().expect("host-gate mutex poisoned");
141        slots
142            .entry(authority.to_string())
143            .or_insert_with(|| Arc::new(HostSlot::new(self.per_host)))
144            .clone()
145    }
146
147    /// Resolve the slot for a URL (derive its authority, get-or-create). Convenience shared by
148    /// every public entry point.
149    fn slot_for_url(&self, url: &str) -> Arc<HostSlot> {
150        self.slot_for(&authority_of(url))
151    }
152
153    /// Acquire the right to send a request to `url`'s host, waiting out any active cooldown /
154    /// spacing. Returns the permit guard (released on drop, including on cancellation) or a
155    /// [`RssError::RateLimited`] if the required wait exceeds `max_gate_wait`.
156    pub async fn acquire(&self, url: &str) -> Result<OwnedSemaphorePermit, RssError> {
157        let slot = self.slot_for_url(url);
158
159        // Concurrency cap. `acquire_owned` yields a permit carrying no borrow, so the guard is
160        // `Send` and RAII-released even if the caller's future is dropped mid-flight.
161        let permit = slot
162            .sem
163            .clone()
164            .acquire_owned()
165            .await
166            .map_err(|_| RssError::Other("rate-limiter semaphore closed".to_string()))?;
167
168        // `next_allowed - now` is a non-negative millisecond count; the conversion is total.
169        let now = now_ms();
170        let target = slot.next_allowed_ms.load(Ordering::Relaxed);
171        let wait = Duration::from_millis(u64::try_from((target - now).max(0)).unwrap_or(0));
172        if wait > self.max_gate_wait {
173            // Beyond the block ceiling: shed to a paceable error rather than hang. The permit
174            // drops here (RAII), freeing the host for the next caller.
175            return Err(RssError::RateLimited {
176                url: url.to_string(),
177                retry_after: wait,
178            });
179        }
180        if !wait.is_zero() {
181            tokio::time::sleep(wait).await;
182        }
183
184        // Sticky spacing: while the host is warm, reserve a gap before the *next* sibling so a
185        // serialized batch is paced, not just serialized. No effect on a cold host.
186        let now = now_ms();
187        if slot.warm_until_ms.load(Ordering::Relaxed) > now {
188            let spaced = now + ms(self.sticky_spacing);
189            slot.next_allowed_ms.fetch_max(spaced, Ordering::Relaxed);
190        }
191
192        Ok(permit)
193    }
194
195    /// The cooldown to apply on a throttle. Honors the server's `retry_after` (capped above
196    /// only — no lower floor, since sticky spacing already prevents re-hammering); when the
197    /// server sent none, escalates `base · 2^(n-1)` with `n` = consecutive throttles, capped
198    /// at `max_cooldown`. Pure (no clock, no state) so it is directly unit-tested.
199    fn cooldown_for(&self, n: u32, retry_after: Option<Duration>) -> Duration {
200        match retry_after {
201            Some(d) => d.min(self.max_cooldown),
202            None => {
203                // `min(30)` only guards `1u32 << shift` from panicking (shift ≥ 32); the real
204                // ceiling is `max_cooldown` via the `.min` below (saturating_mul handles the
205                // Duration side).
206                let shift = n.saturating_sub(1).min(30);
207                self.base_cooldown
208                    .saturating_mul(1u32 << shift)
209                    .min(self.max_cooldown)
210            }
211        }
212    }
213
214    /// Record a `403`/`429` from `url`'s host: extend the sibling cooldown and mark the host
215    /// warm. `retry_after` is the server's honored value (any form) when present; otherwise a
216    /// bounded escalation of the base cooldown is used.
217    pub fn note_throttled(&self, url: &str, retry_after: Option<Duration>) {
218        let slot = self.slot_for_url(url);
219        let n = slot.consecutive_throttles.fetch_add(1, Ordering::Relaxed) + 1;
220        let cooldown = self.cooldown_for(n, retry_after);
221
222        let now = now_ms();
223        slot.next_allowed_ms
224            .fetch_max(now + ms(cooldown), Ordering::Relaxed);
225        slot.warm_until_ms
226            .fetch_max(now + ms(self.warm_window), Ordering::Relaxed);
227    }
228
229    /// Record a non-throttled response from `url`'s host: reset the escalation counter so the
230    /// next throttle starts from the base cooldown again. `warm_until` is left to decay on its
231    /// own, so a host that just recovered keeps light spacing briefly.
232    pub fn note_success(&self, url: &str) {
233        self.slot_for_url(url)
234            .consecutive_throttles
235            .store(0, Ordering::Relaxed);
236    }
237}
238
239/// Milliseconds of a `Duration` as `i64` (saturating — durations here are always small).
240fn ms(d: Duration) -> i64 {
241    i64::try_from(d.as_millis()).unwrap_or(i64::MAX)
242}
243
244/// Parse an environment variable into `T`, or `None` if unset/blank/unparseable.
245fn env_parse<T: std::str::FromStr>(key: &str) -> Option<T> {
246    std::env::var(key).ok()?.trim().parse().ok()
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn gate() -> HostGate {
254        HostGate::default()
255    }
256
257    #[test]
258    fn authority_normalizes_host_and_port() {
259        assert_eq!(
260            authority_of("https://Example.com/feed.xml"),
261            "example.com:443"
262        );
263        assert_eq!(authority_of("http://example.com/feed"), "example.com:80");
264        assert_eq!(
265            authority_of("https://example.com:8443/x"),
266            "example.com:8443"
267        );
268        // Different hosts are different buckets; same host different path is the same bucket.
269        assert_ne!(
270            authority_of("https://a.example.com/x"),
271            authority_of("https://b.example.com/x")
272        );
273        assert_eq!(
274            authority_of("https://example.com/x"),
275            authority_of("https://example.com/y")
276        );
277    }
278
279    #[tokio::test]
280    async fn cold_host_acquires_without_waiting() {
281        let g = gate();
282        // No prior throttle: acquire is immediate and sets no cooldown.
283        let t0 = now_ms();
284        let _p = g.acquire("https://example.com/feed").await.unwrap();
285        assert!(now_ms() - t0 < 200, "cold acquire must not sleep");
286    }
287
288    #[tokio::test]
289    async fn cap_one_serializes_same_authority() {
290        let g = gate();
291        let p1 = g.acquire("https://example.com/a").await.unwrap();
292        // With permits=1 the second acquire cannot complete while p1 is held.
293        let fut = g.acquire("https://example.com/b");
294        tokio::pin!(fut);
295        assert!(
296            tokio::time::timeout(Duration::from_millis(100), &mut fut)
297                .await
298                .is_err(),
299            "second same-host acquire should block while the first permit is held"
300        );
301        drop(p1);
302        // Once released, it proceeds.
303        assert!(fut.await.is_ok());
304    }
305
306    #[tokio::test]
307    async fn distinct_authorities_do_not_block_each_other() {
308        let g = gate();
309        let _a = g.acquire("https://a.example.com/x").await.unwrap();
310        // Different host → different slot → different semaphore → no contention.
311        let b = tokio::time::timeout(
312            Duration::from_millis(100),
313            g.acquire("https://b.example.com/x"),
314        )
315        .await;
316        assert!(
317            b.is_ok(),
318            "a distinct host must not be gated behind another"
319        );
320    }
321
322    #[tokio::test]
323    async fn throttle_makes_sibling_wait_but_not_a_different_host() {
324        let g = gate();
325        // Simulate a throttle with an explicit short Retry-After so the test stays fast.
326        let t_throttled = now_ms();
327        g.note_throttled(
328            "https://slow.example.com/x",
329            Some(Duration::from_millis(300)),
330        );
331
332        // A different host is unaffected (generous upper bound — a cold acquire is a no-op,
333        // but keep slack so a loaded CI runner can't flake this).
334        let t0 = now_ms();
335        let _other = g.acquire("https://fast.example.com/y").await.unwrap();
336        assert!(now_ms() - t0 < 300, "unrelated host must not wait");
337
338        // The throttled host's next request waits out (roughly) the cooldown. Measure from
339        // before the throttle was recorded so scheduler jitter in the intervening cold acquire
340        // cannot eat into the lower bound and flake the test.
341        let _same = g.acquire("https://slow.example.com/z").await.unwrap();
342        let waited = now_ms() - t_throttled;
343        assert!(
344            (250..3000).contains(&waited),
345            "throttled host should wait ~the cooldown, waited {waited}ms"
346        );
347    }
348
349    #[test]
350    fn cooldown_for_escalates_doubling_and_caps() {
351        let g = HostGate {
352            base_cooldown: Duration::from_secs(2),
353            max_cooldown: Duration::from_secs(60),
354            ..HostGate::default()
355        };
356        // Headerless: base · 2^(n-1), i.e. 2s, 4s, 8s, 16s, 32s, then clamped at 60s.
357        assert_eq!(g.cooldown_for(1, None), Duration::from_secs(2));
358        assert_eq!(g.cooldown_for(2, None), Duration::from_secs(4));
359        assert_eq!(g.cooldown_for(3, None), Duration::from_secs(8));
360        assert_eq!(g.cooldown_for(4, None), Duration::from_secs(16));
361        assert_eq!(g.cooldown_for(5, None), Duration::from_secs(32));
362        assert_eq!(g.cooldown_for(6, None), Duration::from_secs(60)); // 64s clamped to 60
363        assert_eq!(g.cooldown_for(7, None), Duration::from_secs(60));
364        // Large n must not panic on the shift and stays clamped (regression guard for the
365        // shift-cap: only the max_cooldown ceiling should bind).
366        assert_eq!(g.cooldown_for(1000, None), Duration::from_secs(60));
367        // Honored Retry-After is capped above only (no lower floor) — sticky spacing is the floor.
368        assert_eq!(
369            g.cooldown_for(1, Some(Duration::from_millis(300))),
370            Duration::from_millis(300)
371        );
372        assert_eq!(
373            g.cooldown_for(9, Some(Duration::from_secs(600))),
374            Duration::from_secs(60)
375        );
376    }
377
378    #[test]
379    fn note_success_resets_escalation_counter() {
380        let g = gate();
381        let url = "https://esc.example.com/x";
382        g.note_throttled(url, None);
383        g.note_throttled(url, None);
384        assert_eq!(
385            g.slot_for("esc.example.com:443")
386                .consecutive_throttles
387                .load(Ordering::Relaxed),
388            2
389        );
390        g.note_success(url);
391        assert_eq!(
392            g.slot_for("esc.example.com:443")
393                .consecutive_throttles
394                .load(Ordering::Relaxed),
395            0,
396            "a success must reset the escalation counter so the next throttle starts from base"
397        );
398    }
399
400    #[tokio::test]
401    async fn warm_host_spaces_the_next_sibling() {
402        // A warm host must not just serialize — it must leave a gap before the NEXT sibling
403        // (sticky spacing). Use a tiny cooldown + spacing so the test stays fast.
404        let g = HostGate {
405            sticky_spacing: Duration::from_millis(300),
406            ..HostGate::default()
407        };
408        let url = "https://warm.example.com/x";
409        // Mark warm with a near-zero cooldown so the first acquire drains it immediately but
410        // the host stays warm (warm_until is far in the future).
411        g.note_throttled(url, Some(Duration::from_millis(1)));
412        let _first = g.acquire(url).await.unwrap(); // drains the ~1ms cooldown, reserves +spacing
413        // The next sibling must wait ~sticky_spacing even though the cooldown is long gone.
414        let t = now_ms();
415        drop(_first);
416        let _second = g.acquire(url).await.unwrap();
417        let waited = now_ms() - t;
418        assert!(
419            (200..2000).contains(&waited),
420            "a warm host should space the next sibling by ~sticky_spacing, waited {waited}ms"
421        );
422    }
423
424    #[tokio::test]
425    async fn zero_host_concurrency_is_floored_and_does_not_deadlock() {
426        // A per_host of 0 would make Semaphore::new(0) block every acquire forever. HostSlot::new
427        // floors permits to >=1; pin that guard (from_env applies the same floor to its env
428        // input). We avoid mutating process env in a parallel test — it races concurrent getenv.
429        let g = HostGate {
430            per_host: 0,
431            ..HostGate::default()
432        };
433        let acquired = tokio::time::timeout(
434            Duration::from_millis(500),
435            g.acquire("https://z.example.com/x"),
436        )
437        .await;
438        assert!(
439            acquired.is_ok(),
440            "per_host must be floored to 1 so acquire never deadlocks"
441        );
442    }
443
444    #[tokio::test]
445    async fn wait_beyond_ceiling_fails_fast_with_rate_limited() {
446        let g = HostGate {
447            max_gate_wait: Duration::from_millis(100),
448            max_cooldown: Duration::from_secs(60),
449            ..HostGate::default()
450        };
451        let url = "https://busy.example.com/x";
452        // A long honored Retry-After pushes next_allowed far beyond the 100ms ceiling.
453        g.note_throttled(url, Some(Duration::from_secs(30)));
454
455        let err = g.acquire(url).await.unwrap_err();
456        match err {
457            RssError::RateLimited { retry_after, .. } => {
458                assert!(
459                    retry_after >= Duration::from_secs(1),
460                    "should report a real wait"
461                );
462            }
463            other => panic!("expected RateLimited, got {other:?}"),
464        }
465    }
466
467    #[tokio::test]
468    async fn cancelled_acquire_releases_the_permit() {
469        let g = gate();
470        let p1 = g.acquire("https://cx.example.com/a").await.unwrap();
471        // A second acquire that we cancel (drop the future) must not leak the (not-yet-held)
472        // permit; and once p1 drops the host is usable again.
473        {
474            let fut = g.acquire("https://cx.example.com/b");
475            tokio::pin!(fut);
476            let _ = tokio::time::timeout(Duration::from_millis(50), &mut fut).await;
477            // `fut` dropped here (cancelled while waiting on the semaphore).
478        }
479        drop(p1);
480        assert!(
481            tokio::time::timeout(
482                Duration::from_millis(200),
483                g.acquire("https://cx.example.com/c")
484            )
485            .await
486            .is_ok(),
487            "host must be acquirable after a cancelled waiter and a released permit"
488        );
489    }
490}