use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HostLimit {
pub queries: u32,
pub window: Duration,
pub concurrency: usize,
}
impl HostLimit {
#[must_use]
pub const fn per_second(queries: u32, concurrency: usize) -> Self {
Self {
queries,
window: Duration::from_secs(1),
concurrency,
}
}
#[must_use]
pub const fn per_minute(queries: u32, concurrency: usize) -> Self {
Self {
queries,
window: Duration::from_secs(60),
concurrency,
}
}
#[must_use]
pub fn per_second_rate(&self) -> f64 {
let seconds = self.window.as_secs_f64();
if seconds <= 0.0 {
return f64::from(self.queries);
}
f64::from(self.queries) / seconds
}
}
const PUBLISHED_LIMITS: &[(&str, HostLimit)] = &[
(
"rdap.identitydigital.services",
HostLimit::per_second(10, 4),
),
(
"rdap.nominet.uk",
HostLimit {
queries: 6,
window: Duration::from_secs(3),
concurrency: 2,
},
),
("rdap.centralnic.com", HostLimit::per_second(2, 2)),
(
"rdap.publicinterestregistry.org",
HostLimit::per_minute(10, 1),
),
("rdap.norid.no", HostLimit::per_minute(10, 1)),
("rdap.isnic.is", HostLimit::per_minute(50, 2)),
("rdap.nic.google", HostLimit::per_second(1, 1)),
("whois.nic.uk", HostLimit::per_second(5, 2)),
("whois.pir.org", HostLimit::per_minute(10, 1)),
("whois.centralnic.com", HostLimit::per_second(2, 2)),
(
"whois.identitydigital.services",
HostLimit::per_second(10, 4),
),
];
pub const CAUTIOUS_LIMIT: HostLimit = HostLimit::per_second(2, 2);
#[must_use]
pub fn published_limit(host: &str) -> Option<HostLimit> {
let host = host.trim().trim_end_matches('.').to_lowercase();
PUBLISHED_LIMITS
.iter()
.find(|(name, _)| *name == host)
.map(|(_, limit)| *limit)
}
#[must_use]
pub fn starting_limit(host: &str) -> HostLimit {
published_limit(host).unwrap_or(CAUTIOUS_LIMIT)
}
pub const MAX_RETRY_AFTER: Duration = Duration::from_secs(120);
pub const DEFAULT_RETRY_AFTER: Duration = Duration::from_secs(30);
#[must_use]
pub fn clamp_retry_after(hint: Option<Duration>) -> Duration {
match hint {
Some(value) => value.min(MAX_RETRY_AFTER),
None => DEFAULT_RETRY_AFTER,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn published_hosts_are_matched_case_and_dot_insensitively() {
assert!(published_limit("rdap.identitydigital.services").is_some());
assert!(published_limit("RDAP.IdentityDigital.Services").is_some());
assert!(published_limit("rdap.identitydigital.services.").is_some());
assert!(published_limit(" rdap.identitydigital.services ").is_some());
}
#[test]
fn an_unknown_host_gets_the_cautious_allowance() {
assert_eq!(starting_limit("rdap.example.invalid"), CAUTIOUS_LIMIT);
assert!(published_limit("rdap.example.invalid").is_none());
}
#[test]
fn the_strictest_and_loosest_published_limits_are_far_apart() {
let strict = starting_limit("rdap.publicinterestregistry.org");
let loose = starting_limit("rdap.identitydigital.services");
assert!(loose.per_second_rate() > strict.per_second_rate() * 50.0);
}
#[test]
fn rates_convert_from_any_window() {
assert!((HostLimit::per_second(10, 1).per_second_rate() - 10.0).abs() < f64::EPSILON);
assert!((HostLimit::per_minute(60, 1).per_second_rate() - 1.0).abs() < f64::EPSILON);
let three = HostLimit {
queries: 6,
window: Duration::from_secs(3),
concurrency: 1,
};
assert!((three.per_second_rate() - 2.0).abs() < f64::EPSILON);
}
#[test]
fn a_retry_hint_is_capped_and_a_missing_one_gets_the_default() {
assert_eq!(
clamp_retry_after(Some(Duration::from_secs(5))),
Duration::from_secs(5)
);
assert_eq!(
clamp_retry_after(Some(Duration::from_secs(86_400))),
MAX_RETRY_AFTER
);
assert_eq!(clamp_retry_after(None), DEFAULT_RETRY_AFTER);
assert_eq!(clamp_retry_after(Some(Duration::ZERO)), Duration::ZERO);
}
#[test]
fn every_published_entry_is_usable() {
for (host, limit) in PUBLISHED_LIMITS {
assert!(!host.is_empty());
assert!(limit.queries > 0, "{host} has a zero rate");
assert!(limit.concurrency > 0, "{host} has zero concurrency");
assert!(!limit.window.is_zero(), "{host} has a zero window");
assert_eq!(*host, host.to_lowercase(), "{host} must be lowercase");
}
}
}