1use 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
27const HOST_MAX_CONCURRENCY: usize = 1;
31
32const HOST_BASE_COOLDOWN: Duration = Duration::from_secs(2);
35
36const HOST_MAX_COOLDOWN: Duration = Duration::from_secs(60);
39
40const STICKY_SPACING: Duration = Duration::from_secs(1);
43
44const WARM_WINDOW: Duration = Duration::from_secs(120);
46
47const MAX_GATE_WAIT: Duration = Duration::from_secs(60);
53
54fn now_ms() -> i64 {
56 Utc::now().timestamp_millis()
57}
58
59fn 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
72struct HostSlot {
75 sem: Arc<Semaphore>,
77 next_allowed_ms: AtomicI64,
79 warm_until_ms: AtomicI64,
81 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
96pub 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 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 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 fn slot_for_url(&self, url: &str) -> Arc<HostSlot> {
150 self.slot_for(&authority_of(url))
151 }
152
153 pub async fn acquire(&self, url: &str) -> Result<OwnedSemaphorePermit, RssError> {
157 let slot = self.slot_for_url(url);
158
159 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 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 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 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 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 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 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 pub fn note_success(&self, url: &str) {
233 self.slot_for_url(url)
234 .consecutive_throttles
235 .store(0, Ordering::Relaxed);
236 }
237}
238
239fn ms(d: Duration) -> i64 {
241 i64::try_from(d.as_millis()).unwrap_or(i64::MAX)
242}
243
244fn 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 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 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 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 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 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 let t_throttled = now_ms();
327 g.note_throttled(
328 "https://slow.example.com/x",
329 Some(Duration::from_millis(300)),
330 );
331
332 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 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 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)); assert_eq!(g.cooldown_for(7, None), Duration::from_secs(60));
364 assert_eq!(g.cooldown_for(1000, None), Duration::from_secs(60));
367 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 let g = HostGate {
405 sticky_spacing: Duration::from_millis(300),
406 ..HostGate::default()
407 };
408 let url = "https://warm.example.com/x";
409 g.note_throttled(url, Some(Duration::from_millis(1)));
412 let _first = g.acquire(url).await.unwrap(); 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 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 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 {
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 }
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}