reserve-core 0.2.0

Core lookup, catalog, and rate-limiting engine behind the reserve domain finder
Documentation
use std::time::Duration;

/// @docgen Registries differ more than sixty-fold, so one global rate is either far too slow or a guaranteed block.
#[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
    }
}

/// @docgen Every figure here comes from the operator's own published policy page, not from measurement or guesswork.
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),
    ),
];

/// @docgen An unpublished limit is discovered by climbing, never by assuming, because guessing high costs a block measured in hours.
pub const CAUTIOUS_LIMIT: HostLimit = HostLimit::per_second(2, 2);

/// @docgen The pacing key standing for the DNS resolver, which is not a registry and must not inherit a registry's politeness figure.
pub const RESOLVER_HOST: &str = "dns.resolver.local";

/// @docgen A recursive resolver serves thousands a second, so the cautious registry default would throttle the whole DNS stage to a crawl.
pub const RESOLVER_LIMIT: HostLimit = HostLimit::per_second(50, 16);

#[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 {
    if host.eq_ignore_ascii_case(RESOLVER_HOST) {
        return RESOLVER_LIMIT;
    }
    published_limit(host).unwrap_or(CAUTIOUS_LIMIT)
}

/// @docgen A hostile or mistaken hint of a full day would otherwise stall the whole sweep.
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 {

    #[test]
    fn the_resolver_is_not_paced_like_a_registry() {
        let resolver = starting_limit(RESOLVER_HOST);
        assert!(
            resolver.per_second_rate() > CAUTIOUS_LIMIT.per_second_rate(),
            "a recursive resolver must not inherit the cautious registry allowance"
        );
        assert!(resolver.concurrency > CAUTIOUS_LIMIT.concurrency);
    }
    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");
        }
    }
}