1mod policy;
4
5use std::collections::HashMap;
6use std::collections::hash_map::RandomState;
7use std::hash::{BuildHasher, Hasher};
8use std::num::NonZeroU32;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::Duration;
12
13use governor::clock::DefaultClock;
14use governor::state::{InMemoryState, NotKeyed};
15use governor::{Jitter, Quota, RateLimiter};
16use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore};
17use tokio::time::Instant;
18
19pub use policy::{
20 CAUTIOUS_LIMIT, DEFAULT_RETRY_AFTER, HostLimit, MAX_RETRY_AFTER, RESOLVER_HOST, RESOLVER_LIMIT,
21 clamp_retry_after, published_limit, starting_limit,
22};
23
24const PROBE_DEADLINE: Duration = Duration::from_secs(30);
25
26type TokenBucket = RateLimiter<NotKeyed, InMemoryState, DefaultClock>;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Refusal {
31 Throttled { retry_after: Option<Duration> },
32 Blocked,
33 Dropped,
34}
35
36impl Refusal {
37 #[must_use]
38 fn base_wait(&self) -> Duration {
39 match self {
40 Self::Throttled { retry_after } => clamp_retry_after(*retry_after),
41 Self::Blocked => MAX_RETRY_AFTER,
42 Self::Dropped => DEFAULT_RETRY_AFTER,
43 }
44 }
45
46 #[must_use]
47 const fn has_stated_wait(&self) -> bool {
48 matches!(
49 self,
50 Self::Throttled {
51 retry_after: Some(_)
52 }
53 )
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct PacingLimits {
59 pub total_concurrency: usize,
60 pub refusal_threshold: u32,
61 pub recovery_threshold: u32,
62 pub jitter: Duration,
64 pub max_backoff: Duration,
65 pub is_cautious: bool,
66 pub per_registry: Option<usize>,
67 pub rate: Option<u32>,
68}
69
70impl Default for PacingLimits {
71 fn default() -> Self {
72 Self {
73 total_concurrency: 24,
74 refusal_threshold: 3,
75 recovery_threshold: 8,
76 jitter: Duration::from_millis(120),
77 max_backoff: Duration::from_secs(120),
78 is_cautious: false,
79 per_registry: None,
80 rate: None,
81 }
82 }
83}
84
85impl PacingLimits {
86 #[must_use]
87 pub fn cautious() -> Self {
88 Self {
89 total_concurrency: 8,
90 refusal_threshold: 2,
91 recovery_threshold: 16,
92 jitter: Duration::from_millis(300),
93 is_cautious: true,
94 ..Self::default()
95 }
96 }
97
98 fn limit_for(&self, host: &str) -> HostLimit {
99 let mut limit = if self.is_cautious {
100 CAUTIOUS_LIMIT
101 } else {
102 starting_limit(host)
103 };
104 if let Some(concurrency) = self.per_registry {
105 limit.concurrency = concurrency.max(1);
106 }
107 if let Some(queries) = self.rate {
108 limit.queries = queries.max(1);
109 limit.window = Duration::from_secs(1);
110 }
111
112 if self.is_cautious {
114 limit.concurrency = limit.concurrency.min(CAUTIOUS_LIMIT.concurrency);
115 if limit.per_second_rate() > CAUTIOUS_LIMIT.per_second_rate() {
116 limit.queries = CAUTIOUS_LIMIT.queries;
117 limit.window = CAUTIOUS_LIMIT.window;
118 }
119 }
120 limit
121 }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum BreakerState {
126 Closed,
127 Open,
128 HalfOpen,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct PausedHost {
133 pub host: String,
134 pub remaining_wait: Duration,
135 pub refusals: u32,
136}
137
138#[derive(Debug)]
140pub struct RequestPermit {
141 host: String,
142 is_probe: bool,
143 _global_slot: OwnedSemaphorePermit,
144 _host_slot: OwnedSemaphorePermit,
145}
146
147impl RequestPermit {
148 #[must_use]
149 pub const fn is_probe(&self) -> bool {
150 self.is_probe
151 }
152
153 #[must_use]
154 pub fn host(&self) -> &str {
155 &self.host
156 }
157}
158
159#[derive(Debug)]
160struct HostState {
161 limiter: Arc<TokenBucket>,
162 slots: Arc<Semaphore>,
163 concurrency: usize,
164 max_concurrency: usize,
165 refusals: u32,
166 successes: u32,
167 open_until: Option<Instant>,
168 is_probing: bool,
169}
170
171fn usable_permits(requested: usize) -> usize {
173 requested.clamp(1, Semaphore::MAX_PERMITS)
174}
175
176impl HostState {
177 fn new(limit: HostLimit) -> Self {
178 Self {
179 limiter: Arc::new(RateLimiter::direct(quota_for(limit))),
180 slots: Arc::new(Semaphore::new(usable_permits(limit.concurrency))),
181 concurrency: limit.concurrency.max(1),
182 max_concurrency: limit.concurrency.max(1),
183 refusals: 0,
184 successes: 0,
185 open_until: None,
186 is_probing: false,
187 }
188 }
189
190 fn breaker(&self, now: Instant) -> BreakerState {
191 match self.open_until {
192 Some(until) if now < until => BreakerState::Open,
193 Some(_) => BreakerState::HalfOpen,
194 None => {
195 if self.is_probing {
196 BreakerState::HalfOpen
197 } else {
198 BreakerState::Closed
199 }
200 }
201 }
202 }
203}
204
205fn quota_for(limit: HostLimit) -> Quota {
206 let rate = limit.per_second_rate().max(0.05);
207 let interval = Duration::from_secs_f64(1.0 / rate);
208 let burst = NonZeroU32::new(limit.queries.max(1)).unwrap_or(NonZeroU32::MIN);
209 Quota::with_period(interval).map_or_else(
210 || Quota::per_second(NonZeroU32::MIN),
211 |quota| quota.allow_burst(burst),
212 )
213}
214
215#[derive(Debug)]
216struct JitterRng(AtomicU64);
217
218impl JitterRng {
219 fn new() -> Self {
220 let seed = RandomState::new().build_hasher().finish() | 1;
221 Self(AtomicU64::new(seed))
222 }
223
224 fn next_u64(&self) -> u64 {
225 let mut x = self.0.load(Ordering::Relaxed);
226 x ^= x << 13;
227 x ^= x >> 7;
228 x ^= x << 17;
229 self.0.store(x, Ordering::Relaxed);
230 x
231 }
232
233 fn sample_up_to(&self, ceiling: Duration) -> Duration {
235 let nanos = u64::try_from(ceiling.as_nanos()).unwrap_or(u64::MAX);
236 if nanos == 0 {
237 return Duration::ZERO;
238 }
239 Duration::from_nanos(self.next_u64() % nanos.saturating_add(1))
240 }
241}
242
243#[derive(Debug)]
244pub struct Pacer {
245 pacing: PacingLimits,
246 global_slots: Arc<Semaphore>,
247 hosts: Mutex<HashMap<String, HostState>>,
248 jitter: JitterRng,
249}
250
251impl Pacer {
252 #[must_use]
253 pub fn new(pacing: PacingLimits) -> Self {
254 Self {
255 global_slots: Arc::new(Semaphore::new(usable_permits(pacing.total_concurrency))),
256 hosts: Mutex::new(HashMap::new()),
257 jitter: JitterRng::new(),
258 pacing,
259 }
260 }
261
262 #[must_use]
263 pub const fn pacing(&self) -> &PacingLimits {
264 &self.pacing
265 }
266
267 pub async fn acquire_patiently(
269 &self,
270 host: &str,
271 budget: Duration,
272 ) -> Result<RequestPermit, PausedHost> {
273 match self.acquire(host).await {
274 Ok(permit) => Ok(permit),
275 Err(paused) => {
276 let wait = paused.remaining_wait;
277 if wait.is_zero() || wait > budget {
278 return Err(paused);
279 }
280 tokio::time::sleep(wait).await;
281 self.acquire(host).await
282 }
283 }
284 }
285
286 pub async fn acquire(&self, host: &str) -> Result<RequestPermit, PausedHost> {
287 let key = normalize_host(host);
288
289 let (limiter, slots, probe) = {
290 let mut hosts = self.hosts.lock().await;
291 let state = hosts
292 .entry(key.clone())
293 .or_insert_with(|| HostState::new(self.pacing.limit_for(&key)));
294
295 let now = Instant::now();
296 match state.breaker(now) {
297 BreakerState::Open => {
298 let remaining = state
299 .open_until
300 .map_or(Duration::ZERO, |until| until.saturating_duration_since(now));
301 return Err(PausedHost {
302 host: key,
303 remaining_wait: remaining,
304 refusals: state.refusals,
305 });
306 }
307 BreakerState::HalfOpen => {
308 if state.is_probing {
309 return Err(PausedHost {
310 host: key,
311 remaining_wait: Duration::ZERO,
312 refusals: state.refusals,
313 });
314 }
315 state.is_probing = true;
317 state.open_until = Some(now + PROBE_DEADLINE);
318 (Arc::clone(&state.limiter), Arc::clone(&state.slots), true)
319 }
320 BreakerState::Closed => {
321 (Arc::clone(&state.limiter), Arc::clone(&state.slots), false)
322 }
323 }
324 };
325
326 if self.pacing.jitter.is_zero() {
328 limiter.until_ready().await;
329 } else {
330 limiter
331 .until_ready_with_jitter(Jitter::up_to(self.pacing.jitter))
332 .await;
333 }
334
335 let host_permit = slots
336 .acquire_owned()
337 .await
338 .map_err(|_| self.shutdown_pause(&key))?;
339 let global = Arc::clone(&self.global_slots)
340 .acquire_owned()
341 .await
342 .map_err(|_| self.shutdown_pause(&key))?;
343
344 Ok(RequestPermit {
345 host: key,
346 is_probe: probe,
347 _global_slot: global,
348 _host_slot: host_permit,
349 })
350 }
351
352 fn shutdown_pause(&self, host: &str) -> PausedHost {
353 PausedHost {
354 host: host.to_owned(),
355 remaining_wait: Duration::ZERO,
356 refusals: 0,
357 }
358 }
359
360 pub async fn record_success(&self, host: &str) {
362 let key = normalize_host(host);
363 let mut hosts = self.hosts.lock().await;
364 let Some(state) = hosts.get_mut(&key) else {
365 return;
366 };
367
368 state.refusals = 0;
369 state.open_until = None;
370 state.is_probing = false;
371 state.successes = state.successes.saturating_add(1);
372
373 if state.successes >= self.pacing.recovery_threshold
374 && state.concurrency < state.max_concurrency
375 {
376 state.successes = 0;
377 state.concurrency = state
378 .concurrency
379 .saturating_add(1)
380 .min(state.max_concurrency);
381 state.slots.add_permits(1);
382 tracing::debug!(host = %key, concurrency = state.concurrency, "registry concurrency raised");
383 }
384 }
385
386 pub async fn record_refusal(&self, host: &str, refusal: &Refusal) -> Duration {
387 let key = normalize_host(host);
388 let mut hosts = self.hosts.lock().await;
389 let state = hosts
390 .entry(key.clone())
391 .or_insert_with(|| HostState::new(self.pacing.limit_for(&key)));
392
393 state.refusals = state.refusals.saturating_add(1);
394 state.successes = 0;
395 state.is_probing = false;
396
397 let wait = if refusal.has_stated_wait() {
398 refusal.base_wait()
399 } else {
400 let exponent = state.refusals.saturating_sub(1).min(6);
401 let ceiling = refusal
402 .base_wait()
403 .saturating_mul(1_u32 << exponent)
404 .min(self.pacing.max_backoff);
405 self.jitter.sample_up_to(ceiling)
406 };
407 let wait = wait.min(self.pacing.max_backoff);
408
409 if state.concurrency > 1 {
410 let target = state
412 .concurrency
413 .saturating_mul(3)
414 .div_ceil(4)
415 .min(state.concurrency.saturating_sub(1))
416 .max(1);
417 let surplus = state.concurrency.saturating_sub(target);
418 if surplus > 0 {
419 let forgotten = state.slots.forget_permits(surplus);
421 state.concurrency = state.concurrency.saturating_sub(forgotten);
422 tracing::debug!(host = %key, concurrency = state.concurrency, "registry concurrency cut");
423 }
424 }
425
426 let should_open =
427 matches!(refusal, Refusal::Blocked) || state.refusals >= self.pacing.refusal_threshold;
428 if should_open {
429 state.open_until = Some(Instant::now() + wait);
430 tracing::debug!(host = %key, refusals = state.refusals, ?wait, "registry paused");
431 }
432
433 wait
434 }
435
436 pub async fn breaker(&self, host: &str) -> BreakerState {
437 let key = normalize_host(host);
438 let hosts = self.hosts.lock().await;
439 hosts
440 .get(&key)
441 .map_or(BreakerState::Closed, |state| state.breaker(Instant::now()))
442 }
443
444 pub async fn paused(&self, host: &str) -> Option<PausedHost> {
445 let key = normalize_host(host);
446 let mut hosts = self.hosts.lock().await;
447 let state = hosts.get_mut(&key)?;
448 let open_until = state.open_until?;
449 let now = Instant::now();
450 if now >= open_until {
451 return None;
452 }
453 Some(PausedHost {
454 host: key,
455 remaining_wait: open_until.saturating_duration_since(now),
456 refusals: state.refusals,
457 })
458 }
459
460 pub async fn paused_hosts(&self) -> Vec<PausedHost> {
461 let now = Instant::now();
462 let hosts = self.hosts.lock().await;
463 let mut paused: Vec<PausedHost> = hosts
464 .iter()
465 .filter_map(|(host, state)| {
466 let open_until = state.open_until?;
467 (open_until > now).then(|| PausedHost {
468 host: host.clone(),
469 remaining_wait: open_until.saturating_duration_since(now),
470 refusals: state.refusals,
471 })
472 })
473 .collect();
474 paused.sort_by(|a, b| a.host.cmp(&b.host));
475 paused
476 }
477
478 pub async fn host_concurrency(&self, host: &str) -> usize {
479 let key = normalize_host(host);
480 let hosts = self.hosts.lock().await;
481 hosts.get(&key).map_or(0, |state| state.concurrency)
482 }
483
484 pub async fn prune_settled_hosts(&self) {
486 let now = Instant::now();
487 let mut hosts = self.hosts.lock().await;
488 hosts.retain(|_, state| {
489 state.refusals > 0
490 || state.is_probing
491 || state.concurrency < state.max_concurrency
492 || state.open_until.is_some_and(|until| until > now)
493 });
494 }
495}
496
497fn normalize_host(host: &str) -> String {
498 host.trim().trim_end_matches('.').to_lowercase()
499}
500
501#[cfg(test)]
502mod tests {
503
504 #[test]
505 fn cautious_cannot_be_raised_by_a_flag_that_asks_for_more() {
506 let reckless = PacingLimits {
507 rate: Some(500),
508 per_registry: Some(64),
509 ..PacingLimits::cautious()
510 };
511 let limit = reckless.limit_for("rdap.example");
512
513 assert!(
514 limit.per_second_rate() <= CAUTIOUS_LIMIT.per_second_rate(),
515 "cautious means the rate can only go down, never up"
516 );
517 assert!(limit.concurrency <= CAUTIOUS_LIMIT.concurrency);
518 }
519
520 #[test]
521 fn cautious_still_lets_a_flag_ask_for_less() {
522 let slower = PacingLimits {
523 rate: Some(1),
524 per_registry: Some(1),
525 ..PacingLimits::cautious()
526 };
527 let limit = slower.limit_for("rdap.example");
528
529 assert_eq!(limit.queries, 1);
530 assert_eq!(limit.concurrency, 1);
531 }
532 use super::*;
533
534 fn throttled(seconds: u64) -> Refusal {
535 Refusal::Throttled {
536 retry_after: Some(Duration::from_secs(seconds)),
537 }
538 }
539
540 const UNSTATED: Refusal = Refusal::Throttled { retry_after: None };
541
542 fn fast() -> PacingLimits {
543 PacingLimits {
544 jitter: Duration::ZERO,
545 ..PacingLimits::default()
546 }
547 }
548
549 #[tokio::test(start_paused = true)]
550 async fn a_lease_is_granted_and_released() {
551 let pacer = Pacer::new(fast());
552 let lease = pacer
553 .acquire("rdap.example.test")
554 .await
555 .expect("first lease");
556 assert_eq!(lease.host(), "rdap.example.test");
557 assert!(!lease.is_probe());
558 drop(lease);
559 assert!(pacer.acquire("rdap.example.test").await.is_ok());
560 }
561
562 #[tokio::test(start_paused = true)]
563 async fn hosts_are_keyed_case_and_dot_insensitively() {
564 let pacer = Pacer::new(fast());
565 pacer
566 .record_refusal("RDAP.Example.Test.", &throttled(30))
567 .await;
568 pacer
569 .record_refusal("rdap.example.test", &throttled(30))
570 .await;
571 pacer
572 .record_refusal("rdap.example.test", &throttled(30))
573 .await;
574 assert!(pacer.paused("rdap.example.test").await.is_some());
575 }
576
577 #[tokio::test(start_paused = true)]
578 async fn a_published_registry_starts_with_its_published_allowance() {
579 let pacer = Pacer::new(fast());
580 let _lease = pacer
581 .acquire("rdap.identitydigital.services")
582 .await
583 .unwrap();
584 assert_eq!(
585 pacer
586 .host_concurrency("rdap.identitydigital.services")
587 .await,
588 4
589 );
590 }
591
592 #[tokio::test(start_paused = true)]
593 async fn gentle_pacing_ignores_a_generous_published_allowance() {
594 let pacer = Pacer::new(PacingLimits {
595 jitter: Duration::ZERO,
596 ..PacingLimits::cautious()
597 });
598 let _lease = pacer
599 .acquire("rdap.identitydigital.services")
600 .await
601 .unwrap();
602 assert_eq!(
603 pacer
604 .host_concurrency("rdap.identitydigital.services")
605 .await,
606 CAUTIOUS_LIMIT.concurrency
607 );
608 }
609
610 #[tokio::test(start_paused = true)]
611 async fn the_breaker_stays_shut_until_the_threshold_is_reached() {
612 let pacer = Pacer::new(PacingLimits {
613 refusal_threshold: 3,
614 ..fast()
615 });
616 pacer.record_refusal("slow.test", &UNSTATED).await;
617 assert_eq!(pacer.breaker("slow.test").await, BreakerState::Closed);
618 pacer.record_refusal("slow.test", &UNSTATED).await;
619 assert_eq!(pacer.breaker("slow.test").await, BreakerState::Closed);
620 pacer.record_refusal("slow.test", &UNSTATED).await;
621 assert_eq!(pacer.breaker("slow.test").await, BreakerState::Open);
622 }
623
624 #[tokio::test(start_paused = true)]
625 async fn an_outright_block_opens_the_breaker_on_the_first_refusal() {
626 let pacer = Pacer::new(PacingLimits {
627 refusal_threshold: 99,
628 ..fast()
629 });
630 pacer
631 .record_refusal("blocked.test", &Refusal::Blocked)
632 .await;
633 assert_eq!(pacer.breaker("blocked.test").await, BreakerState::Open);
634 }
635
636 #[tokio::test(start_paused = true)]
637 async fn a_dropped_connection_counts_as_backpressure() {
638 let pacer = Pacer::new(PacingLimits {
639 refusal_threshold: 1,
640 ..fast()
641 });
642 pacer.record_refusal("quiet.test", &Refusal::Dropped).await;
643 assert_eq!(pacer.breaker("quiet.test").await, BreakerState::Open);
644 }
645
646 #[tokio::test(start_paused = true)]
647 async fn an_open_breaker_refuses_a_lease_instead_of_blocking() {
648 let pacer = Pacer::new(PacingLimits {
649 refusal_threshold: 1,
650 ..fast()
651 });
652 pacer.record_refusal("busy.test", &throttled(5)).await;
653
654 match pacer.acquire("busy.test").await {
655 Err(paused) => {
656 assert_eq!(paused.host, "busy.test");
657 assert!(paused.remaining_wait <= Duration::from_secs(5));
658 }
659 Ok(_) => panic!("an open breaker must refuse the lease"),
660 }
661 }
662
663 #[tokio::test(start_paused = true)]
664 async fn the_cooldown_lets_exactly_one_probe_through() {
665 let pacer = Pacer::new(PacingLimits {
666 refusal_threshold: 1,
667 ..fast()
668 });
669 pacer.record_refusal("recovering.test", &throttled(2)).await;
670 tokio::time::advance(Duration::from_secs(3)).await;
671
672 assert_eq!(
673 pacer.breaker("recovering.test").await,
674 BreakerState::HalfOpen
675 );
676 let probe = pacer
677 .acquire("recovering.test")
678 .await
679 .expect("probe allowed");
680 assert!(probe.is_probe());
681
682 assert!(
683 pacer.acquire("recovering.test").await.is_err(),
684 "only one probe may be in flight"
685 );
686 }
687
688 #[tokio::test(start_paused = true)]
689 async fn a_successful_probe_closes_the_breaker() {
690 let pacer = Pacer::new(PacingLimits {
691 refusal_threshold: 1,
692 ..fast()
693 });
694 pacer.record_refusal("healing.test", &throttled(2)).await;
695 tokio::time::advance(Duration::from_secs(3)).await;
696 let probe = pacer.acquire("healing.test").await.expect("probe");
697 drop(probe);
698
699 pacer.record_success("healing.test").await;
700 assert_eq!(pacer.breaker("healing.test").await, BreakerState::Closed);
701 assert!(pacer.acquire("healing.test").await.is_ok());
702 }
703
704 #[tokio::test(start_paused = true)]
705 async fn a_failed_probe_reopens_the_breaker() {
706 let pacer = Pacer::new(PacingLimits {
707 refusal_threshold: 1,
708 ..fast()
709 });
710 pacer.record_refusal("stubborn.test", &throttled(2)).await;
711 tokio::time::advance(Duration::from_secs(3)).await;
712 let probe = pacer.acquire("stubborn.test").await.expect("probe");
713 drop(probe);
714
715 pacer.record_refusal("stubborn.test", &throttled(4)).await;
716 assert_eq!(pacer.breaker("stubborn.test").await, BreakerState::Open);
717 }
718
719 #[tokio::test(start_paused = true)]
720 async fn a_stated_wait_is_honored_exactly() {
721 let pacer = Pacer::new(fast());
722 let wait = pacer.record_refusal("polite.test", &throttled(7)).await;
723 assert_eq!(wait, Duration::from_secs(7));
724 }
725
726 #[tokio::test(start_paused = true)]
727 async fn an_absurd_stated_wait_is_capped() {
728 let pacer = Pacer::new(fast());
729 let wait = pacer
730 .record_refusal("hostile.test", &throttled(86_400))
731 .await;
732 assert_eq!(wait, MAX_RETRY_AFTER);
733 }
734
735 #[tokio::test(start_paused = true)]
736 async fn an_unstated_wait_stays_inside_the_growing_ceiling() {
737 let pacer = Pacer::new(PacingLimits {
738 refusal_threshold: 99,
739 ..fast()
740 });
741 for _ in 0..12 {
742 let wait = pacer.record_refusal("steep.test", &UNSTATED).await;
743 assert!(
744 wait <= pacer.pacing().max_backoff,
745 "{wait:?} exceeded the cap"
746 );
747 }
748 }
749
750 #[tokio::test(start_paused = true)]
751 async fn a_refusal_cuts_the_allowance_and_success_earns_it_back() {
752 let pacer = Pacer::new(PacingLimits {
753 refusal_threshold: 99,
754 recovery_threshold: 2,
755 ..fast()
756 });
757 let host = "rdap.identitydigital.services";
758 let lease = pacer.acquire(host).await.unwrap();
759 drop(lease);
760 assert_eq!(pacer.host_concurrency(host).await, 4);
761
762 pacer.record_refusal(host, &UNSTATED).await;
763 assert_eq!(pacer.host_concurrency(host).await, 3);
764
765 for _ in 0..2 {
766 pacer.record_success(host).await;
767 }
768 assert_eq!(pacer.host_concurrency(host).await, 4);
769 }
770
771 #[tokio::test(start_paused = true)]
772 async fn a_refusal_still_cuts_a_host_that_starts_at_two() {
773 let pacer = Pacer::new(PacingLimits {
775 refusal_threshold: 99,
776 ..fast()
777 });
778 let host = "unpublished.test";
779 let lease = pacer.acquire(host).await.unwrap();
780 drop(lease);
781 assert_eq!(pacer.host_concurrency(host).await, 2);
782
783 pacer.record_refusal(host, &UNSTATED).await;
784 assert_eq!(
785 pacer.host_concurrency(host).await,
786 1,
787 "backpressure must reach a host that starts at the cautious limit"
788 );
789 }
790
791 #[tokio::test(start_paused = true)]
792 async fn the_allowance_never_climbs_past_where_it_started() {
793 let pacer = Pacer::new(PacingLimits {
794 recovery_threshold: 1,
795 ..fast()
796 });
797 let host = "rdap.identitydigital.services";
798 let lease = pacer.acquire(host).await.unwrap();
799 drop(lease);
800
801 for _ in 0..50 {
802 pacer.record_success(host).await;
803 }
804 assert_eq!(pacer.host_concurrency(host).await, 4);
805 }
806
807 #[tokio::test(start_paused = true)]
808 async fn paused_hosts_lists_only_the_registries_actually_on_hold() {
809 let pacer = Pacer::new(PacingLimits {
810 refusal_threshold: 1,
811 ..fast()
812 });
813 pacer.record_refusal("one.test", &throttled(10)).await;
814 pacer.record_success("two.test").await;
815
816 let paused = pacer.paused_hosts().await;
817 assert_eq!(paused.len(), 1);
818 assert_eq!(paused.first().map(|p| p.host.as_str()), Some("one.test"));
819 }
820
821 #[tokio::test(start_paused = true)]
822 async fn tidy_keeps_troubled_hosts_and_drops_settled_ones() {
823 let pacer = Pacer::new(PacingLimits {
824 refusal_threshold: 1,
825 ..fast()
826 });
827 let settled = pacer.acquire("calm.test").await.unwrap();
828 drop(settled);
829 pacer.record_success("calm.test").await;
830 pacer.record_refusal("angry.test", &throttled(60)).await;
831
832 pacer.prune_settled_hosts().await;
833 assert_eq!(pacer.host_concurrency("calm.test").await, 0);
834 assert!(pacer.paused("angry.test").await.is_some());
835 }
836
837 #[test]
838 fn full_jitter_draws_inside_the_window_and_actually_varies() {
839 let jitterer = JitterRng::new();
840 let ceiling = Duration::from_secs(10);
841 let draws: Vec<Duration> = (0..64).map(|_| jitterer.sample_up_to(ceiling)).collect();
842
843 assert!(draws.iter().all(|d| *d <= ceiling));
844 let unique = draws
845 .iter()
846 .collect::<std::collections::BTreeSet<_>>()
847 .len();
848 assert!(
849 unique > 32,
850 "jitter is not spreading: {unique} distinct draws"
851 );
852 }
853
854 #[test]
855 fn a_zero_window_yields_no_wait_rather_than_panicking() {
856 let jitterer = JitterRng::new();
857 assert_eq!(jitterer.sample_up_to(Duration::ZERO), Duration::ZERO);
858 }
859
860 #[test]
861 fn a_quota_survives_an_extremely_slow_published_limit() {
862 let quota = quota_for(HostLimit::per_minute(1, 1));
863 assert!(quota.burst_size().get() >= 1);
864 }
865}