Skip to main content

reserve_core/limit/
policy.rs

1use std::time::Duration;
2
3/// @docgen Registries differ more than sixty-fold, so one global rate is either far too slow or a guaranteed block.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct HostLimit {
6    pub queries: u32,
7    pub window: Duration,
8    pub concurrency: usize,
9}
10
11impl HostLimit {
12    #[must_use]
13    pub const fn per_second(queries: u32, concurrency: usize) -> Self {
14        Self {
15            queries,
16            window: Duration::from_secs(1),
17            concurrency,
18        }
19    }
20
21    #[must_use]
22    pub const fn per_minute(queries: u32, concurrency: usize) -> Self {
23        Self {
24            queries,
25            window: Duration::from_secs(60),
26            concurrency,
27        }
28    }
29
30    #[must_use]
31    pub fn per_second_rate(&self) -> f64 {
32        let seconds = self.window.as_secs_f64();
33        if seconds <= 0.0 {
34            return f64::from(self.queries);
35        }
36        f64::from(self.queries) / seconds
37    }
38}
39
40/// @docgen Every figure here comes from the operator's own published policy page, not from measurement or guesswork.
41const PUBLISHED_LIMITS: &[(&str, HostLimit)] = &[
42    (
43        "rdap.identitydigital.services",
44        HostLimit::per_second(10, 4),
45    ),
46    (
47        "rdap.nominet.uk",
48        HostLimit {
49            queries: 6,
50            window: Duration::from_secs(3),
51            concurrency: 2,
52        },
53    ),
54    ("rdap.centralnic.com", HostLimit::per_second(2, 2)),
55    (
56        "rdap.publicinterestregistry.org",
57        HostLimit::per_minute(10, 1),
58    ),
59    ("rdap.norid.no", HostLimit::per_minute(10, 1)),
60    ("rdap.isnic.is", HostLimit::per_minute(50, 2)),
61    ("rdap.nic.google", HostLimit::per_second(1, 1)),
62    ("whois.nic.uk", HostLimit::per_second(5, 2)),
63    ("whois.pir.org", HostLimit::per_minute(10, 1)),
64    ("whois.centralnic.com", HostLimit::per_second(2, 2)),
65    (
66        "whois.identitydigital.services",
67        HostLimit::per_second(10, 4),
68    ),
69];
70
71/// @docgen An unpublished limit is discovered by climbing, never by assuming, because guessing high costs a block measured in hours.
72pub const CAUTIOUS_LIMIT: HostLimit = HostLimit::per_second(2, 2);
73
74#[must_use]
75pub fn published_limit(host: &str) -> Option<HostLimit> {
76    let host = host.trim().trim_end_matches('.').to_lowercase();
77    PUBLISHED_LIMITS
78        .iter()
79        .find(|(name, _)| *name == host)
80        .map(|(_, limit)| *limit)
81}
82
83#[must_use]
84pub fn starting_limit(host: &str) -> HostLimit {
85    published_limit(host).unwrap_or(CAUTIOUS_LIMIT)
86}
87
88/// @docgen A hostile or mistaken hint of a full day would otherwise stall the whole sweep.
89pub const MAX_RETRY_AFTER: Duration = Duration::from_secs(120);
90
91pub const DEFAULT_RETRY_AFTER: Duration = Duration::from_secs(30);
92
93#[must_use]
94pub fn clamp_retry_after(hint: Option<Duration>) -> Duration {
95    match hint {
96        Some(value) => value.min(MAX_RETRY_AFTER),
97        None => DEFAULT_RETRY_AFTER,
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn published_hosts_are_matched_case_and_dot_insensitively() {
107        assert!(published_limit("rdap.identitydigital.services").is_some());
108        assert!(published_limit("RDAP.IdentityDigital.Services").is_some());
109        assert!(published_limit("rdap.identitydigital.services.").is_some());
110        assert!(published_limit(" rdap.identitydigital.services ").is_some());
111    }
112
113    #[test]
114    fn an_unknown_host_gets_the_cautious_allowance() {
115        assert_eq!(starting_limit("rdap.example.invalid"), CAUTIOUS_LIMIT);
116        assert!(published_limit("rdap.example.invalid").is_none());
117    }
118
119    #[test]
120    fn the_strictest_and_loosest_published_limits_are_far_apart() {
121        let strict = starting_limit("rdap.publicinterestregistry.org");
122        let loose = starting_limit("rdap.identitydigital.services");
123        assert!(loose.per_second_rate() > strict.per_second_rate() * 50.0);
124    }
125
126    #[test]
127    fn rates_convert_from_any_window() {
128        assert!((HostLimit::per_second(10, 1).per_second_rate() - 10.0).abs() < f64::EPSILON);
129        assert!((HostLimit::per_minute(60, 1).per_second_rate() - 1.0).abs() < f64::EPSILON);
130        let three = HostLimit {
131            queries: 6,
132            window: Duration::from_secs(3),
133            concurrency: 1,
134        };
135        assert!((three.per_second_rate() - 2.0).abs() < f64::EPSILON);
136    }
137
138    #[test]
139    fn a_retry_hint_is_capped_and_a_missing_one_gets_the_default() {
140        assert_eq!(
141            clamp_retry_after(Some(Duration::from_secs(5))),
142            Duration::from_secs(5)
143        );
144        assert_eq!(
145            clamp_retry_after(Some(Duration::from_secs(86_400))),
146            MAX_RETRY_AFTER
147        );
148        assert_eq!(clamp_retry_after(None), DEFAULT_RETRY_AFTER);
149        assert_eq!(clamp_retry_after(Some(Duration::ZERO)), Duration::ZERO);
150    }
151
152    #[test]
153    fn every_published_entry_is_usable() {
154        for (host, limit) in PUBLISHED_LIMITS {
155            assert!(!host.is_empty());
156            assert!(limit.queries > 0, "{host} has a zero rate");
157            assert!(limit.concurrency > 0, "{host} has zero concurrency");
158            assert!(!limit.window.is_zero(), "{host} has a zero window");
159            assert_eq!(*host, host.to_lowercase(), "{host} must be lowercase");
160        }
161    }
162}