reserve_core/limit/
policy.rs1use std::time::Duration;
2
3#[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
40const 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
71pub const CAUTIOUS_LIMIT: HostLimit = HostLimit::per_second(2, 2);
73
74pub const RESOLVER_HOST: &str = "dns.resolver.local";
76
77pub const RESOLVER_LIMIT: HostLimit = HostLimit::per_second(50, 16);
79
80#[must_use]
81pub fn published_limit(host: &str) -> Option<HostLimit> {
82 let host = host.trim().trim_end_matches('.').to_lowercase();
83 PUBLISHED_LIMITS
84 .iter()
85 .find(|(name, _)| *name == host)
86 .map(|(_, limit)| *limit)
87}
88
89#[must_use]
90pub fn starting_limit(host: &str) -> HostLimit {
91 if host.eq_ignore_ascii_case(RESOLVER_HOST) {
92 return RESOLVER_LIMIT;
93 }
94 published_limit(host).unwrap_or(CAUTIOUS_LIMIT)
95}
96
97pub const MAX_RETRY_AFTER: Duration = Duration::from_secs(120);
99
100pub const DEFAULT_RETRY_AFTER: Duration = Duration::from_secs(30);
101
102#[must_use]
103pub fn clamp_retry_after(hint: Option<Duration>) -> Duration {
104 match hint {
105 Some(value) => value.min(MAX_RETRY_AFTER),
106 None => DEFAULT_RETRY_AFTER,
107 }
108}
109
110#[cfg(test)]
111mod tests {
112
113 #[test]
114 fn the_resolver_is_not_paced_like_a_registry() {
115 let resolver = starting_limit(RESOLVER_HOST);
116 assert!(
117 resolver.per_second_rate() > CAUTIOUS_LIMIT.per_second_rate(),
118 "a recursive resolver must not inherit the cautious registry allowance"
119 );
120 assert!(resolver.concurrency > CAUTIOUS_LIMIT.concurrency);
121 }
122 use super::*;
123
124 #[test]
125 fn published_hosts_are_matched_case_and_dot_insensitively() {
126 assert!(published_limit("rdap.identitydigital.services").is_some());
127 assert!(published_limit("RDAP.IdentityDigital.Services").is_some());
128 assert!(published_limit("rdap.identitydigital.services.").is_some());
129 assert!(published_limit(" rdap.identitydigital.services ").is_some());
130 }
131
132 #[test]
133 fn an_unknown_host_gets_the_cautious_allowance() {
134 assert_eq!(starting_limit("rdap.example.invalid"), CAUTIOUS_LIMIT);
135 assert!(published_limit("rdap.example.invalid").is_none());
136 }
137
138 #[test]
139 fn the_strictest_and_loosest_published_limits_are_far_apart() {
140 let strict = starting_limit("rdap.publicinterestregistry.org");
141 let loose = starting_limit("rdap.identitydigital.services");
142 assert!(loose.per_second_rate() > strict.per_second_rate() * 50.0);
143 }
144
145 #[test]
146 fn rates_convert_from_any_window() {
147 assert!((HostLimit::per_second(10, 1).per_second_rate() - 10.0).abs() < f64::EPSILON);
148 assert!((HostLimit::per_minute(60, 1).per_second_rate() - 1.0).abs() < f64::EPSILON);
149 let three = HostLimit {
150 queries: 6,
151 window: Duration::from_secs(3),
152 concurrency: 1,
153 };
154 assert!((three.per_second_rate() - 2.0).abs() < f64::EPSILON);
155 }
156
157 #[test]
158 fn a_retry_hint_is_capped_and_a_missing_one_gets_the_default() {
159 assert_eq!(
160 clamp_retry_after(Some(Duration::from_secs(5))),
161 Duration::from_secs(5)
162 );
163 assert_eq!(
164 clamp_retry_after(Some(Duration::from_secs(86_400))),
165 MAX_RETRY_AFTER
166 );
167 assert_eq!(clamp_retry_after(None), DEFAULT_RETRY_AFTER);
168 assert_eq!(clamp_retry_after(Some(Duration::ZERO)), Duration::ZERO);
169 }
170
171 #[test]
172 fn every_published_entry_is_usable() {
173 for (host, limit) in PUBLISHED_LIMITS {
174 assert!(!host.is_empty());
175 assert!(limit.queries > 0, "{host} has a zero rate");
176 assert!(limit.concurrency > 0, "{host} has zero concurrency");
177 assert!(!limit.window.is_zero(), "{host} has a zero window");
178 assert_eq!(*host, host.to_lowercase(), "{host} must be lowercase");
179 }
180 }
181}