Skip to main content

reserve_core/limit/
mod.rs

1//! @docgen Pacing is keyed by registry host, never by extension: one operator answers for hundreds of extensions from one endpoint.
2
3mod 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/// @docgen The three refusals need different waits, and a dropped connection is backpressure rather than a network error, so they never collapse.
29#[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    /// @docgen A spread is added before each request so a fan-out does not land on a registry as one spike.
63    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        // @docgen Cautious is a ceiling, not a starting point: a flag that raises the rate would make the word mean its opposite.
113        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/// @docgen The two unread permit fields must stay: dropping them is what frees the global and per-host slots.
139#[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
171/// @docgen The semaphore panics above its own permit ceiling, and this crate is published on its own, so the clamp cannot live only in the binary.
172fn 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    /// @docgen Full jitter spreads retries across the whole window instead of clustering them at its end.
234    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    /// @docgen The breaker is fail-fast, so without waiting out its own pause a single refusal turns a whole zone unknown.
268    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                    // @docgen A probe whose future is dropped never records an outcome, so the deadline lets the host recover on its own.
316                    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        // @docgen The rate wait comes first so a global slot is never held idle while a slow registry's bucket refills.
327        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    /// @docgen Concurrency climbs one step per run of successes but is cut hard on any refusal, so recovery cannot re-trigger a block.
361    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            // @docgen Three-quarters alone has fixed points at 2 and 3, where most registries sit, so the cut would never fire.
411            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                // @docgen Tokio only forgets permits that are free, so the shortfall must carry rather than be assumed applied.
420                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    /// @docgen A long sweep would otherwise keep a map entry for every endpoint it ever touched.
485    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        // @docgen Most registries start at the cautious limit of two, where a three-quarter rounding cut has a fixed point.
774        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}