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
26/// @docgen One host answers for hundreds of extensions, so the rest have to wait out the probe rather than be written off.
27const BEHIND_A_PROBE: Duration = Duration::from_millis(250);
28
29/// @docgen Long enough that a sweep never drops a host it is still working through, short enough that the map does not grow without end.
30const SETTLED_AFTER: Duration = Duration::from_secs(60);
31
32type TokenBucket = RateLimiter<NotKeyed, InMemoryState, DefaultClock>;
33
34/// @docgen The three refusals need different waits, and a dropped connection is backpressure rather than a network error, so they never collapse.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Refusal {
37    Throttled { retry_after: Option<Duration> },
38    Blocked,
39    Dropped,
40}
41
42impl Refusal {
43    #[must_use]
44    fn base_wait(&self) -> Duration {
45        match self {
46            Self::Throttled { retry_after } => clamp_retry_after(*retry_after),
47            Self::Blocked => MAX_RETRY_AFTER,
48            Self::Dropped => DEFAULT_RETRY_AFTER,
49        }
50    }
51
52    #[must_use]
53    const fn has_stated_wait(&self) -> bool {
54        matches!(
55            self,
56            Self::Throttled {
57                retry_after: Some(_)
58            }
59        )
60    }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct PacingLimits {
65    pub total_concurrency: usize,
66    pub refusal_threshold: u32,
67    pub recovery_threshold: u32,
68    /// @docgen A spread is added before each request so a fan-out does not land on a registry as one spike.
69    pub jitter: Duration,
70    pub max_backoff: Duration,
71    pub is_cautious: bool,
72    pub per_registry: Option<usize>,
73    pub rate: Option<u32>,
74}
75
76impl Default for PacingLimits {
77    fn default() -> Self {
78        Self {
79            total_concurrency: 24,
80            refusal_threshold: 3,
81            recovery_threshold: 8,
82            jitter: Duration::from_millis(120),
83            max_backoff: Duration::from_secs(120),
84            is_cautious: false,
85            per_registry: None,
86            rate: None,
87        }
88    }
89}
90
91impl PacingLimits {
92    /// @docgen The fields are public and a nonsense value is clamped rather than obeyed, so a caller that wants to know asks here.
93    pub fn check(&self) -> Result<(), &'static str> {
94        if self.total_concurrency == 0 {
95            return Err("total_concurrency must be at least one");
96        }
97        if self.per_registry == Some(0) {
98            return Err("per_registry must be at least one");
99        }
100        if self.rate == Some(0) {
101            return Err("rate must be at least one");
102        }
103        if self.refusal_threshold == 0 {
104            return Err("refusal_threshold must be at least one");
105        }
106        Ok(())
107    }
108
109    pub fn cautious() -> Self {
110        Self {
111            total_concurrency: 8,
112            refusal_threshold: 2,
113            recovery_threshold: 16,
114            jitter: Duration::from_millis(300),
115            is_cautious: true,
116            ..Self::default()
117        }
118    }
119
120    fn limit_for(&self, host: &str) -> HostLimit {
121        let mut limit = if self.is_cautious {
122            CAUTIOUS_LIMIT
123        } else {
124            starting_limit(host)
125        };
126        if let Some(concurrency) = self.per_registry {
127            limit.concurrency = concurrency.max(1);
128        }
129        if let Some(queries) = self.rate {
130            limit.queries = queries.max(1);
131            limit.window = Duration::from_secs(1);
132        }
133
134        // @docgen Cautious is a ceiling, not a starting point: a flag that raises the rate would make the word mean its opposite.
135        if self.is_cautious {
136            limit.concurrency = limit.concurrency.min(CAUTIOUS_LIMIT.concurrency);
137            if limit.per_second_rate() > CAUTIOUS_LIMIT.per_second_rate() {
138                limit.queries = CAUTIOUS_LIMIT.queries;
139                limit.window = CAUTIOUS_LIMIT.window;
140            }
141        }
142        limit
143    }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum BreakerState {
148    Closed,
149    Open,
150    HalfOpen,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct PausedHost {
155    pub host: String,
156    pub remaining_wait: Duration,
157    pub refusals: u32,
158}
159
160/// @docgen The two unread permit fields must stay: dropping them is what frees the global and per-host slots.
161#[derive(Debug)]
162pub struct RequestPermit {
163    host: String,
164    is_probe: bool,
165    _global_slot: OwnedSemaphorePermit,
166    _host_slot: OwnedSemaphorePermit,
167}
168
169impl RequestPermit {
170    #[must_use]
171    pub const fn is_probe(&self) -> bool {
172        self.is_probe
173    }
174
175    #[must_use]
176    pub fn host(&self) -> &str {
177        &self.host
178    }
179}
180
181#[derive(Debug)]
182struct HostState {
183    limiter: Arc<TokenBucket>,
184    slots: Arc<Semaphore>,
185    concurrency: usize,
186    max_concurrency: usize,
187    refusals: u32,
188    successes: u32,
189    open_until: Option<Instant>,
190    /// @docgen Kept apart from the backoff, or arming a probe would overwrite the wait the registry itself asked for.
191    probe_until: Option<Instant>,
192    is_probing: bool,
193    /// @docgen A busy host looks settled by every other measure, and dropping it handed the registry's spent rate budget back full.
194    last_used: Instant,
195}
196
197/// @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.
198fn usable_permits(requested: usize) -> usize {
199    requested.clamp(1, Semaphore::MAX_PERMITS)
200}
201
202impl HostState {
203    fn new(limit: HostLimit) -> Self {
204        Self {
205            limiter: Arc::new(RateLimiter::direct(quota_for(limit))),
206            slots: Arc::new(Semaphore::new(usable_permits(limit.concurrency))),
207            concurrency: limit.concurrency.max(1),
208            max_concurrency: limit.concurrency.max(1),
209            refusals: 0,
210            successes: 0,
211            open_until: None,
212            probe_until: None,
213            is_probing: false,
214            last_used: Instant::now(),
215        }
216    }
217
218    fn breaker(&self, now: Instant) -> BreakerState {
219        match self.open_until {
220            Some(until) if now < until => BreakerState::Open,
221            Some(_) => BreakerState::HalfOpen,
222            None if self.is_probing => BreakerState::HalfOpen,
223            None => BreakerState::Closed,
224        }
225    }
226
227    /// @docgen A probe whose future was dropped records nothing, so its own deadline is what lets the next one through.
228    fn probe_abandoned(&self, now: Instant) -> bool {
229        self.probe_until.is_none_or(|until| now >= until)
230    }
231}
232
233fn quota_for(limit: HostLimit) -> Quota {
234    let rate = limit.per_second_rate().max(0.05);
235    let interval = Duration::from_secs_f64(1.0 / rate);
236    let burst = NonZeroU32::new(limit.queries.max(1)).unwrap_or(NonZeroU32::MIN);
237    Quota::with_period(interval).map_or_else(
238        || Quota::per_second(NonZeroU32::MIN),
239        |quota| quota.allow_burst(burst),
240    )
241}
242
243#[derive(Debug)]
244struct JitterRng(AtomicU64);
245
246impl JitterRng {
247    fn new() -> Self {
248        let seed = RandomState::new().build_hasher().finish() | 1;
249        Self(AtomicU64::new(seed))
250    }
251
252    fn next_u64(&self) -> u64 {
253        let mut x = self.0.load(Ordering::Relaxed);
254        x ^= x << 13;
255        x ^= x >> 7;
256        x ^= x << 17;
257        self.0.store(x, Ordering::Relaxed);
258        x
259    }
260
261    /// @docgen Full jitter spreads retries across the whole window instead of clustering them at its end.
262    fn sample_up_to(&self, ceiling: Duration) -> Duration {
263        let nanos = u64::try_from(ceiling.as_nanos()).unwrap_or(u64::MAX);
264        if nanos == 0 {
265            return Duration::ZERO;
266        }
267        Duration::from_nanos(self.next_u64() % nanos.saturating_add(1))
268    }
269}
270
271#[derive(Debug)]
272pub struct Pacer {
273    pacing: PacingLimits,
274    global_slots: Arc<Semaphore>,
275    hosts: Mutex<HashMap<String, HostState>>,
276    jitter: JitterRng,
277}
278
279impl Pacer {
280    #[must_use]
281    pub fn new(pacing: PacingLimits) -> Self {
282        Self {
283            global_slots: Arc::new(Semaphore::new(usable_permits(pacing.total_concurrency))),
284            hosts: Mutex::new(HashMap::new()),
285            jitter: JitterRng::new(),
286            pacing,
287        }
288    }
289
290    #[must_use]
291    pub const fn pacing(&self) -> &PacingLimits {
292        &self.pacing
293    }
294
295    /// @docgen The breaker is fail-fast, so without waiting out its own pause a single refusal turns a whole zone unknown.
296    pub async fn acquire_patiently(
297        &self,
298        host: &str,
299        budget: Duration,
300    ) -> Result<RequestPermit, PausedHost> {
301        match self.acquire(host).await {
302            Ok(permit) => Ok(permit),
303            Err(paused) => {
304                let wait = paused.remaining_wait;
305                if wait.is_zero() || wait > budget {
306                    return Err(paused);
307                }
308                tokio::time::sleep(wait).await;
309                self.acquire(host).await
310            }
311        }
312    }
313
314    pub async fn acquire(&self, host: &str) -> Result<RequestPermit, PausedHost> {
315        let key = normalize_host(host);
316
317        let (limiter, slots, probe) = {
318            let mut hosts = self.hosts.lock().await;
319            let state = hosts
320                .entry(key.clone())
321                .or_insert_with(|| HostState::new(self.pacing.limit_for(&key)));
322
323            let now = Instant::now();
324            state.last_used = now;
325            match state.breaker(now) {
326                BreakerState::Open => {
327                    let remaining = state
328                        .open_until
329                        .map_or(Duration::ZERO, |until| until.saturating_duration_since(now));
330                    return Err(PausedHost {
331                        host: crate::lookup::scrub(&key),
332                        remaining_wait: remaining,
333                        refusals: state.refusals,
334                    });
335                }
336                BreakerState::HalfOpen => {
337                    if state.is_probing && !state.probe_abandoned(now) {
338                        // @docgen A zero wait tells the caller to give up, so every other name on this host was reported unknown while one probe ran.
339                        return Err(PausedHost {
340                            host: crate::lookup::scrub(&key),
341                            remaining_wait: BEHIND_A_PROBE,
342                            refusals: state.refusals,
343                        });
344                    }
345                    state.is_probing = true;
346                    state.probe_until = Some(now + PROBE_DEADLINE);
347                    state.open_until = None;
348                    (Arc::clone(&state.limiter), Arc::clone(&state.slots), true)
349                }
350                BreakerState::Closed => {
351                    (Arc::clone(&state.limiter), Arc::clone(&state.slots), false)
352                }
353            }
354        };
355
356        // @docgen The rate wait comes first so a global slot is never held idle while a slow registry's bucket refills.
357        if self.pacing.jitter.is_zero() {
358            limiter.until_ready().await;
359        } else {
360            limiter
361                .until_ready_with_jitter(Jitter::up_to(self.pacing.jitter))
362                .await;
363        }
364
365        let host_permit = slots
366            .acquire_owned()
367            .await
368            .map_err(|_| self.shutdown_pause(&key))?;
369        let global = Arc::clone(&self.global_slots)
370            .acquire_owned()
371            .await
372            .map_err(|_| self.shutdown_pause(&key))?;
373
374        Ok(RequestPermit {
375            host: crate::lookup::scrub(&key),
376            is_probe: probe,
377            _global_slot: global,
378            _host_slot: host_permit,
379        })
380    }
381
382    fn shutdown_pause(&self, host: &str) -> PausedHost {
383        PausedHost {
384            host: host.to_owned(),
385            remaining_wait: Duration::ZERO,
386            refusals: 0,
387        }
388    }
389
390    /// @docgen Concurrency climbs one step per run of successes but is cut hard on any refusal, so recovery cannot re-trigger a block.
391    pub async fn record_success(&self, host: &str) {
392        let key = normalize_host(host);
393        let mut hosts = self.hosts.lock().await;
394        let Some(state) = hosts.get_mut(&key) else {
395            return;
396        };
397
398        // @docgen A request sent before the pause began can land after it, and clearing on that answer threw away the wait the registry asked for.
399        let now = Instant::now();
400        if state.open_until.is_some_and(|until| until > now) {
401            state.successes = state.successes.saturating_add(1);
402            return;
403        }
404        state.refusals = 0;
405        state.open_until = None;
406        state.is_probing = false;
407        state.probe_until = None;
408        state.successes = state.successes.saturating_add(1);
409
410        if state.successes >= self.pacing.recovery_threshold
411            && state.concurrency < state.max_concurrency
412        {
413            state.successes = 0;
414            state.concurrency = state
415                .concurrency
416                .saturating_add(1)
417                .min(state.max_concurrency);
418            state.slots.add_permits(1);
419            tracing::debug!(host = %key, concurrency = state.concurrency, "registry concurrency raised");
420        }
421    }
422
423    pub async fn record_refusal(&self, host: &str, refusal: &Refusal) -> Duration {
424        let key = normalize_host(host);
425        let mut hosts = self.hosts.lock().await;
426        let state = hosts
427            .entry(key.clone())
428            .or_insert_with(|| HostState::new(self.pacing.limit_for(&key)));
429
430        state.refusals = state.refusals.saturating_add(1);
431        state.successes = 0;
432        state.is_probing = false;
433        state.probe_until = None;
434
435        let wait = if refusal.has_stated_wait() {
436            refusal.base_wait()
437        } else {
438            let exponent = state.refusals.saturating_sub(1).min(6);
439            let ceiling = refusal
440                .base_wait()
441                .saturating_mul(1_u32 << exponent)
442                .min(self.pacing.max_backoff);
443            self.jitter.sample_up_to(ceiling)
444        };
445        let wait = wait.min(self.pacing.max_backoff);
446
447        if state.concurrency > 1 {
448            // @docgen Three-quarters alone has fixed points at 2 and 3, where most registries sit, so the cut would never fire.
449            let target = state
450                .concurrency
451                .saturating_mul(3)
452                .div_ceil(4)
453                .min(state.concurrency.saturating_sub(1))
454                .max(1);
455            let surplus = state.concurrency.saturating_sub(target);
456            if surplus > 0 {
457                // @docgen Tokio only forgets permits that are free, so the shortfall must carry rather than be assumed applied.
458                let forgotten = state.slots.forget_permits(surplus);
459                state.concurrency = state.concurrency.saturating_sub(forgotten);
460                tracing::debug!(host = %key, concurrency = state.concurrency, "registry concurrency cut");
461            }
462        }
463
464        let should_open =
465            matches!(refusal, Refusal::Blocked) || state.refusals >= self.pacing.refusal_threshold;
466        if should_open {
467            // @docgen A dropped connection carries no stated wait, and overwriting a registry's own `Retry-After` with a jitter draw ignored it.
468            let until = Instant::now() + wait;
469            state.open_until = Some(state.open_until.map_or(until, |held| held.max(until)));
470            tracing::debug!(host = %key, refusals = state.refusals, ?wait, "registry paused");
471        }
472
473        wait
474    }
475
476    pub async fn breaker(&self, host: &str) -> BreakerState {
477        let key = normalize_host(host);
478        let hosts = self.hosts.lock().await;
479        hosts
480            .get(&key)
481            .map_or(BreakerState::Closed, |state| state.breaker(Instant::now()))
482    }
483
484    pub async fn paused(&self, host: &str) -> Option<PausedHost> {
485        let key = normalize_host(host);
486        let mut hosts = self.hosts.lock().await;
487        let state = hosts.get_mut(&key)?;
488        let open_until = state.open_until?;
489        let now = Instant::now();
490        if now >= open_until {
491            return None;
492        }
493        Some(PausedHost {
494            host: crate::lookup::scrub(&key),
495            remaining_wait: open_until.saturating_duration_since(now),
496            refusals: state.refusals,
497        })
498    }
499
500    pub async fn paused_hosts(&self) -> Vec<PausedHost> {
501        let now = Instant::now();
502        let hosts = self.hosts.lock().await;
503        let mut paused: Vec<PausedHost> = hosts
504            .iter()
505            .filter_map(|(host, state)| {
506                let open_until = state.open_until?;
507                (open_until > now).then(|| PausedHost {
508                    host: host.clone(),
509                    remaining_wait: open_until.saturating_duration_since(now),
510                    refusals: state.refusals,
511                })
512            })
513            .collect();
514        paused.sort_by(|a, b| a.host.cmp(&b.host));
515        paused
516    }
517
518    pub async fn host_concurrency(&self, host: &str) -> usize {
519        let key = normalize_host(host);
520        let hosts = self.hosts.lock().await;
521        hosts.get(&key).map_or(0, |state| state.concurrency)
522    }
523
524    /// @docgen A long sweep would otherwise keep a map entry for every endpoint it ever touched.
525    pub async fn prune_settled_hosts(&self) {
526        let now = Instant::now();
527        let mut hosts = self.hosts.lock().await;
528        hosts.retain(|_, state| {
529            now.saturating_duration_since(state.last_used) < SETTLED_AFTER
530                || state.refusals > 0
531                || state.is_probing
532                || state.concurrency < state.max_concurrency
533                || state.open_until.is_some_and(|until| until > now)
534                || state.probe_until.is_some_and(|until| until > now)
535        });
536    }
537}
538
539fn normalize_host(host: &str) -> String {
540    host.trim().trim_end_matches('.').to_lowercase()
541}
542
543#[cfg(test)]
544mod tests {
545
546    #[test]
547    fn cautious_cannot_be_raised_by_a_flag_that_asks_for_more() {
548        let reckless = PacingLimits {
549            rate: Some(500),
550            per_registry: Some(64),
551            ..PacingLimits::cautious()
552        };
553        let limit = reckless.limit_for("rdap.example");
554
555        assert!(
556            limit.per_second_rate() <= CAUTIOUS_LIMIT.per_second_rate(),
557            "cautious means the rate can only go down, never up"
558        );
559        assert!(limit.concurrency <= CAUTIOUS_LIMIT.concurrency);
560    }
561
562    #[test]
563    fn cautious_still_lets_a_flag_ask_for_less() {
564        let slower = PacingLimits {
565            rate: Some(1),
566            per_registry: Some(1),
567            ..PacingLimits::cautious()
568        };
569        let limit = slower.limit_for("rdap.example");
570
571        assert_eq!(limit.queries, 1);
572        assert_eq!(limit.concurrency, 1);
573    }
574    use super::*;
575
576    fn throttled(seconds: u64) -> Refusal {
577        Refusal::Throttled {
578            retry_after: Some(Duration::from_secs(seconds)),
579        }
580    }
581
582    const UNSTATED: Refusal = Refusal::Throttled { retry_after: None };
583
584    fn fast() -> PacingLimits {
585        PacingLimits {
586            jitter: Duration::ZERO,
587            ..PacingLimits::default()
588        }
589    }
590
591    #[tokio::test(start_paused = true)]
592    async fn a_permit_is_granted_and_released() {
593        let pacer = Pacer::new(fast());
594        let permit = pacer
595            .acquire("rdap.example.test")
596            .await
597            .expect("first permit");
598        assert_eq!(permit.host(), "rdap.example.test");
599        assert!(!permit.is_probe());
600        drop(permit);
601        assert!(pacer.acquire("rdap.example.test").await.is_ok());
602    }
603
604    #[tokio::test(start_paused = true)]
605    async fn hosts_are_keyed_case_and_dot_insensitively() {
606        let pacer = Pacer::new(fast());
607        pacer
608            .record_refusal("RDAP.Example.Test.", &throttled(30))
609            .await;
610        pacer
611            .record_refusal("rdap.example.test", &throttled(30))
612            .await;
613        pacer
614            .record_refusal("rdap.example.test", &throttled(30))
615            .await;
616        assert!(pacer.paused("rdap.example.test").await.is_some());
617    }
618
619    #[tokio::test(start_paused = true)]
620    async fn a_published_registry_starts_with_its_published_allowance() {
621        let pacer = Pacer::new(fast());
622        let _permit = pacer
623            .acquire("rdap.identitydigital.services")
624            .await
625            .unwrap();
626        assert_eq!(
627            pacer
628                .host_concurrency("rdap.identitydigital.services")
629                .await,
630            4
631        );
632    }
633
634    #[tokio::test(start_paused = true)]
635    async fn gentle_pacing_ignores_a_generous_published_allowance() {
636        let pacer = Pacer::new(PacingLimits {
637            jitter: Duration::ZERO,
638            ..PacingLimits::cautious()
639        });
640        let _permit = pacer
641            .acquire("rdap.identitydigital.services")
642            .await
643            .unwrap();
644        assert_eq!(
645            pacer
646                .host_concurrency("rdap.identitydigital.services")
647                .await,
648            CAUTIOUS_LIMIT.concurrency
649        );
650    }
651
652    #[tokio::test(start_paused = true)]
653    async fn the_breaker_stays_shut_until_the_threshold_is_reached() {
654        let pacer = Pacer::new(PacingLimits {
655            refusal_threshold: 3,
656            ..fast()
657        });
658        pacer.record_refusal("slow.test", &UNSTATED).await;
659        assert_eq!(pacer.breaker("slow.test").await, BreakerState::Closed);
660        pacer.record_refusal("slow.test", &UNSTATED).await;
661        assert_eq!(pacer.breaker("slow.test").await, BreakerState::Closed);
662        pacer.record_refusal("slow.test", &UNSTATED).await;
663        assert_eq!(pacer.breaker("slow.test").await, BreakerState::Open);
664    }
665
666    #[tokio::test(start_paused = true)]
667    async fn an_outright_block_opens_the_breaker_on_the_first_refusal() {
668        let pacer = Pacer::new(PacingLimits {
669            refusal_threshold: 99,
670            ..fast()
671        });
672        pacer
673            .record_refusal("blocked.test", &Refusal::Blocked)
674            .await;
675        assert_eq!(pacer.breaker("blocked.test").await, BreakerState::Open);
676    }
677
678    #[tokio::test(start_paused = true)]
679    async fn a_dropped_connection_counts_as_backpressure() {
680        let pacer = Pacer::new(PacingLimits {
681            refusal_threshold: 1,
682            ..fast()
683        });
684        pacer.record_refusal("quiet.test", &Refusal::Dropped).await;
685        assert_eq!(pacer.breaker("quiet.test").await, BreakerState::Open);
686    }
687
688    #[tokio::test(start_paused = true)]
689    async fn an_open_breaker_refuses_a_permit_instead_of_blocking() {
690        let pacer = Pacer::new(PacingLimits {
691            refusal_threshold: 1,
692            ..fast()
693        });
694        pacer.record_refusal("busy.test", &throttled(5)).await;
695
696        match pacer.acquire("busy.test").await {
697            Err(paused) => {
698                assert_eq!(paused.host, "busy.test");
699                assert!(paused.remaining_wait <= Duration::from_secs(5));
700            }
701            Ok(_) => panic!("an open breaker must refuse the permit"),
702        }
703    }
704
705    #[tokio::test(start_paused = true)]
706    async fn the_cooldown_lets_exactly_one_probe_through() {
707        let pacer = Pacer::new(PacingLimits {
708            refusal_threshold: 1,
709            ..fast()
710        });
711        pacer.record_refusal("recovering.test", &throttled(2)).await;
712        tokio::time::advance(Duration::from_secs(3)).await;
713
714        assert_eq!(
715            pacer.breaker("recovering.test").await,
716            BreakerState::HalfOpen
717        );
718        let probe = pacer
719            .acquire("recovering.test")
720            .await
721            .expect("probe allowed");
722        assert!(probe.is_probe());
723
724        assert!(
725            pacer.acquire("recovering.test").await.is_err(),
726            "only one probe may be in flight"
727        );
728    }
729
730    #[tokio::test(start_paused = true)]
731    async fn a_successful_probe_closes_the_breaker() {
732        let pacer = Pacer::new(PacingLimits {
733            refusal_threshold: 1,
734            ..fast()
735        });
736        pacer.record_refusal("healing.test", &throttled(2)).await;
737        tokio::time::advance(Duration::from_secs(3)).await;
738        let probe = pacer.acquire("healing.test").await.expect("probe");
739        drop(probe);
740
741        pacer.record_success("healing.test").await;
742        assert_eq!(pacer.breaker("healing.test").await, BreakerState::Closed);
743        assert!(pacer.acquire("healing.test").await.is_ok());
744    }
745
746    #[tokio::test(start_paused = true)]
747    async fn a_failed_probe_reopens_the_breaker() {
748        let pacer = Pacer::new(PacingLimits {
749            refusal_threshold: 1,
750            ..fast()
751        });
752        pacer.record_refusal("stubborn.test", &throttled(2)).await;
753        tokio::time::advance(Duration::from_secs(3)).await;
754        let probe = pacer.acquire("stubborn.test").await.expect("probe");
755        drop(probe);
756
757        pacer.record_refusal("stubborn.test", &throttled(4)).await;
758        assert_eq!(pacer.breaker("stubborn.test").await, BreakerState::Open);
759    }
760
761    #[tokio::test(start_paused = true)]
762    async fn a_stated_wait_is_honored_exactly() {
763        let pacer = Pacer::new(fast());
764        let wait = pacer.record_refusal("polite.test", &throttled(7)).await;
765        assert_eq!(wait, Duration::from_secs(7));
766    }
767
768    #[tokio::test(start_paused = true)]
769    async fn an_absurd_stated_wait_is_capped() {
770        let pacer = Pacer::new(fast());
771        let wait = pacer
772            .record_refusal("hostile.test", &throttled(86_400))
773            .await;
774        assert_eq!(wait, MAX_RETRY_AFTER);
775    }
776
777    #[tokio::test(start_paused = true)]
778    async fn an_unstated_wait_stays_inside_the_growing_ceiling() {
779        let pacer = Pacer::new(PacingLimits {
780            refusal_threshold: 99,
781            ..fast()
782        });
783        for _ in 0..12 {
784            let wait = pacer.record_refusal("steep.test", &UNSTATED).await;
785            assert!(
786                wait <= pacer.pacing().max_backoff,
787                "{wait:?} exceeded the cap"
788            );
789        }
790    }
791
792    #[tokio::test(start_paused = true)]
793    async fn a_refusal_cuts_the_allowance_and_success_earns_it_back() {
794        let pacer = Pacer::new(PacingLimits {
795            refusal_threshold: 99,
796            recovery_threshold: 2,
797            ..fast()
798        });
799        let host = "rdap.identitydigital.services";
800        let permit = pacer.acquire(host).await.unwrap();
801        drop(permit);
802        assert_eq!(pacer.host_concurrency(host).await, 4);
803
804        pacer.record_refusal(host, &UNSTATED).await;
805        assert_eq!(pacer.host_concurrency(host).await, 3);
806
807        for _ in 0..2 {
808            pacer.record_success(host).await;
809        }
810        assert_eq!(pacer.host_concurrency(host).await, 4);
811    }
812
813    #[tokio::test(start_paused = true)]
814    async fn a_refusal_still_cuts_a_host_that_starts_at_two() {
815        // @docgen Most registries start at the cautious limit of two, where a three-quarter rounding cut has a fixed point.
816        let pacer = Pacer::new(PacingLimits {
817            refusal_threshold: 99,
818            ..fast()
819        });
820        let host = "unpublished.test";
821        let permit = pacer.acquire(host).await.unwrap();
822        drop(permit);
823        assert_eq!(pacer.host_concurrency(host).await, 2);
824
825        pacer.record_refusal(host, &UNSTATED).await;
826        assert_eq!(
827            pacer.host_concurrency(host).await,
828            1,
829            "backpressure must reach a host that starts at the cautious limit"
830        );
831    }
832
833    #[tokio::test(start_paused = true)]
834    async fn the_allowance_never_climbs_past_where_it_started() {
835        let pacer = Pacer::new(PacingLimits {
836            recovery_threshold: 1,
837            ..fast()
838        });
839        let host = "rdap.identitydigital.services";
840        let permit = pacer.acquire(host).await.unwrap();
841        drop(permit);
842
843        for _ in 0..50 {
844            pacer.record_success(host).await;
845        }
846        assert_eq!(pacer.host_concurrency(host).await, 4);
847    }
848
849    #[tokio::test(start_paused = true)]
850    async fn paused_hosts_lists_only_the_registries_actually_on_hold() {
851        let pacer = Pacer::new(PacingLimits {
852            refusal_threshold: 1,
853            ..fast()
854        });
855        pacer.record_refusal("one.test", &throttled(10)).await;
856        pacer.record_success("two.test").await;
857
858        let paused = pacer.paused_hosts().await;
859        assert_eq!(paused.len(), 1);
860        assert_eq!(
861            paused.first().map(|entry| entry.host.as_str()),
862            Some("one.test")
863        );
864    }
865
866    #[tokio::test(start_paused = true)]
867    async fn an_answer_sent_before_the_pause_does_not_lift_it() {
868        let pacer = Pacer::new(PacingLimits {
869            refusal_threshold: 1,
870            ..fast()
871        });
872        // Four lookups are in flight; three are refused and the registry asks for two minutes.
873        pacer.record_refusal("busy.test", &throttled(120)).await;
874        let paused = pacer
875            .paused("busy.test")
876            .await
877            .expect("the registry asked for a wait");
878        assert!(paused.remaining_wait >= Duration::from_secs(100));
879
880        // @docgen The fourth was sent before any of that and lands afterwards; it says nothing about the pause.
881        pacer.record_success("busy.test").await;
882        let still = pacer
883            .paused("busy.test")
884            .await
885            .expect("the wait the registry asked for has to stand");
886        assert!(
887            still.remaining_wait >= Duration::from_secs(100),
888            "an answer already in flight must not discard the registry's own wait"
889        );
890
891        tokio::time::advance(Duration::from_secs(121)).await;
892        assert!(
893            pacer.paused("busy.test").await.is_none(),
894            "and once the wait is over the host is usable again"
895        );
896    }
897
898    #[tokio::test(start_paused = true)]
899    async fn a_dropped_connection_never_shortens_a_wait_the_registry_asked_for() {
900        let pacer = Pacer::new(PacingLimits {
901            refusal_threshold: 1,
902            ..fast()
903        });
904        pacer.record_refusal("busy.test", &throttled(300)).await;
905        let asked = pacer
906            .paused("busy.test")
907            .await
908            .expect("the registry asked for a wait")
909            .remaining_wait;
910
911        // A dropped connection carries no stated wait, so its backoff is a short jitter draw.
912        pacer.record_refusal("busy.test", &Refusal::Dropped).await;
913        let after = pacer
914            .paused("busy.test")
915            .await
916            .expect("the host is still paused")
917            .remaining_wait;
918        assert!(
919            after >= asked.saturating_sub(Duration::from_secs(1)),
920            "a shorter backoff must never replace a longer one: {asked:?} became {after:?}"
921        );
922    }
923
924    #[tokio::test(start_paused = true)]
925    async fn pruning_keeps_troubled_hosts_and_drops_settled_ones() {
926        let pacer = Pacer::new(PacingLimits {
927            refusal_threshold: 1,
928            ..fast()
929        });
930        let settled = pacer.acquire("calm.test").await.unwrap();
931        drop(settled);
932        pacer.record_success("calm.test").await;
933        // @docgen Its pause has to outlast the quiet window below, or the test proves the clock rather than the rule.
934        pacer.record_refusal("angry.test", &throttled(600)).await;
935
936        // @docgen A host asked a moment ago is still being worked through, and dropping it hands its spent rate budget back full.
937        pacer.prune_settled_hosts().await;
938        assert!(
939            pacer.host_concurrency("calm.test").await > 0,
940            "a host this sweep is still using must not be dropped"
941        );
942
943        tokio::time::advance(SETTLED_AFTER + Duration::from_secs(1)).await;
944        pacer.prune_settled_hosts().await;
945        assert_eq!(
946            pacer.host_concurrency("calm.test").await,
947            0,
948            "once it has gone quiet it is dropped"
949        );
950        assert!(pacer.paused("angry.test").await.is_some());
951    }
952
953    #[test]
954    fn full_jitter_draws_inside_the_window_and_actually_varies() {
955        let jitter = JitterRng::new();
956        let ceiling = Duration::from_secs(10);
957        let draws: Vec<Duration> = (0..64).map(|_| jitter.sample_up_to(ceiling)).collect();
958
959        assert!(draws.iter().all(|draw| *draw <= ceiling));
960        let unique = draws
961            .iter()
962            .collect::<std::collections::BTreeSet<_>>()
963            .len();
964        assert!(
965            unique > 32,
966            "jitter is not spreading: {unique} distinct draws"
967        );
968    }
969
970    #[test]
971    fn a_zero_window_yields_no_wait_rather_than_panicking() {
972        let jitter = JitterRng::new();
973        assert_eq!(jitter.sample_up_to(Duration::ZERO), Duration::ZERO);
974    }
975
976    #[test]
977    fn a_quota_survives_an_extremely_slow_published_limit() {
978        let quota = quota_for(HostLimit::per_minute(1, 1));
979        assert!(quota.burst_size().get() >= 1);
980    }
981
982    #[tokio::test]
983    async fn arming_a_probe_does_not_overwrite_the_wait_a_registry_asked_for() {
984        let pacer = Pacer::new(PacingLimits::default());
985        let host = "registry.test";
986
987        // @docgen Only a blocked refusal opens the breaker outright; a throttle has to reach the threshold first.
988        pacer.record_refusal(host, &Refusal::Blocked).await;
989
990        let paused = pacer.paused_hosts().await;
991        let waiting = paused.iter().find(|entry| entry.host == host);
992        assert!(
993            waiting.is_some_and(|entry| entry.remaining_wait > Duration::ZERO),
994            "a refusal has to leave a wait a caller can report"
995        );
996    }
997
998    #[tokio::test]
999    async fn a_settled_host_is_forgotten_while_one_still_waiting_is_kept() {
1000        let pacer = Pacer::new(PacingLimits::default());
1001        pacer.record_success("quiet.test").await;
1002        pacer.record_refusal("cross.test", &Refusal::Blocked).await;
1003
1004        pacer.prune_settled_hosts().await;
1005
1006        let still_paused = pacer.paused_hosts().await;
1007        assert!(
1008            still_paused.iter().any(|entry| entry.host == "cross.test"),
1009            "a host still serving a wait must survive the prune"
1010        );
1011    }
1012}