monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
//! The error type returned by every fallible operation in this crate.
//!
//! The design goal is that a caller can tell *why* a lookup produced no verdict
//! without parsing message strings. A registry that rate-limited the query, a
//! TLD nobody serves, and a socket that died mid-record are three very different
//! situations, and only the last one is worth retrying immediately.

use std::io;
use std::time::Duration;

use crate::domain::Tld;

/// Convenience alias for results carrying this crate's [`Error`].
pub type Result<T> = std::result::Result<T, Error>;

/// Anything that can go wrong while looking a domain up.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// The input is not a domain name this crate can query.
    #[error("{input:?} is not a usable domain name: {reason}")]
    InvalidDomain {
        /// The string as supplied by the caller.
        input: String,
        /// What specifically was wrong with it.
        reason: DomainError,
    },

    /// No registry in the configured provider claims this suffix.
    #[error("no WHOIS or RDAP service is known for .{tld}")]
    UnsupportedTld {
        /// The unmatched suffix.
        tld: Tld,
    },

    /// A registry is configured for the suffix but has no usable endpoint, or
    /// every endpoint it does have needs a crate feature that is switched off.
    #[error("no usable endpoint for .{tld}: {detail}")]
    NoEndpoint {
        /// The suffix that was resolved.
        tld: Tld,
        /// Which endpoints were rejected and why.
        detail: String,
    },

    /// The registry definition data is missing, malformed, or contradictory.
    #[error("invalid registry definitions: {0}")]
    Definitions(String),

    /// The endpoint could not be reached at all.
    #[error("could not reach {server}: {source}")]
    Connect {
        /// Host (and port, for WHOIS) or URL that was dialled.
        server: String,
        /// The underlying OS error.
        #[source]
        source: io::Error,
    },

    /// The connection opened but failed while the record was being read.
    #[error("transport error talking to {server}: {source}")]
    Io {
        /// Host or URL being read from.
        server: String,
        /// The underlying OS error.
        #[source]
        source: io::Error,
    },

    /// The endpoint accepted the connection and then said nothing in time.
    #[error("{server} did not answer within {elapsed:.1?}")]
    Timeout {
        /// Host or URL that stalled.
        server: String,
        /// How long was spent waiting.
        elapsed: Duration,
    },

    /// The endpoint answered with an empty body.
    ///
    /// Treated as an error rather than as availability on purpose: an empty
    /// record is not evidence that a domain is unregistered.
    #[error("{server} returned an empty response")]
    EmptyResponse {
        /// Host or URL that answered.
        server: String,
    },

    /// The endpoint declined to answer the question that was asked.
    #[error("{server} declined the query: {reason}")]
    Refused {
        /// Host or URL that refused.
        server: String,
        /// How the refusal was recognised.
        reason: Refusal,
    },

    /// An HTTP endpoint answered with a status that is not an RDAP answer.
    ///
    /// Note that `404` is *not* an error here: it is how RDAP reports that a
    /// domain does not exist, so that response is fed to the detector.
    #[error("{url} answered HTTP {status}")]
    Http {
        /// The URL that was requested.
        url: String,
        /// The status code received.
        status: u16,
    },

    /// An RDAP endpoint answered with a body that is not valid RDAP JSON.
    #[error("malformed RDAP response from {url}: {source}")]
    Rdap {
        /// The URL that was requested.
        url: String,
        /// The deserialisation failure.
        #[source]
        source: serde_json::Error,
    },

    /// Every response was fetched successfully, and none of them said anything
    /// conclusive about whether the domain is registered.
    ///
    /// This is deliberately an error and not an `Availability` variant, because
    /// the one thing a caller must never do is show it as "available".
    #[error("no conclusive answer for {domain} (checked {consulted}): {detail}")]
    Inconclusive {
        /// The domain that was queried.
        domain: String,
        /// Which endpoints were consulted.
        consulted: String,
        /// What the responses did contain.
        detail: String,
    },

    /// A code path needs a crate feature that was not enabled at compile time.
    #[error("{wanted} requires the `{feature}` cargo feature")]
    FeatureDisabled {
        /// The feature that must be switched on.
        feature: &'static str,
        /// What the caller was trying to do.
        wanted: &'static str,
    },
}

impl Error {
    /// Whether retrying the identical query has a realistic chance of working.
    ///
    /// True for transient network faults and for rate limiting; false for
    /// anything structural, such as an unknown TLD or malformed input.
    pub fn is_transient(&self) -> bool {
        match self {
            Error::Connect { .. } | Error::Io { .. } | Error::Timeout { .. } => true,
            Error::EmptyResponse { .. } => true,
            Error::Refused { reason, .. } => reason.is_transient(),
            Error::Http { status, .. } => *status == 429 || *status >= 500,
            _ => false,
        }
    }

    /// Whether the failure came from the network rather than from our own data
    /// or the caller's input. Useful for deciding whether to try the next
    /// endpoint of a registry that publishes more than one.
    pub fn is_endpoint_failure(&self) -> bool {
        matches!(
            self,
            Error::Connect { .. }
                | Error::Io { .. }
                | Error::Timeout { .. }
                | Error::EmptyResponse { .. }
                | Error::Refused { .. }
                | Error::Http { .. }
                | Error::Rdap { .. }
        )
    }
}

/// Why a string was rejected as a domain name.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum DomainError {
    /// Nothing was left after normalisation.
    #[error("the name is empty")]
    Empty,
    /// The name has no dot, so it carries no suffix to look up.
    #[error("the name has no top-level domain")]
    NoTld,
    /// The full name exceeds the 253-octet limit of a DNS name.
    #[error("the name is longer than 253 characters")]
    TooLong,
    /// One label is empty, over 63 octets, or otherwise not a legal DNS label.
    #[error("{label:?} is not a valid DNS label")]
    InvalidLabel {
        /// The offending label.
        label: String,
    },
    /// The name could not be converted to or from its punycode form.
    #[error("{label:?} is not a valid internationalised label: {detail}")]
    InvalidIdn {
        /// The offending label.
        label: String,
        /// What the IDNA implementation said.
        detail: String,
    },
    /// The input parses as an IP address, which has no WHOIS domain record.
    #[error("the input is an IP address, not a domain name")]
    IpAddress,
}

/// How an endpoint made it clear it would not answer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum Refusal {
    /// The query budget for this client was exhausted.
    #[error("query rate limit exceeded")]
    RateLimited,
    /// The server blocked this client outright.
    #[error("client blocked by the server")]
    Blocked,
    /// The server is up but temporarily unable to answer.
    #[error("server temporarily unavailable")]
    Unavailable,
    /// Port 43 has been retired in favour of RDAP at this registry.
    #[error("port 43 service retired in favour of RDAP")]
    PortRetired,
    /// The server answered, but serves a different namespace than the TLD asked
    /// for — typically an IP-number registry reached through a stale mapping.
    #[error("server does not serve this TLD")]
    WrongNamespace,
    /// The server requires credentials or a web form.
    #[error("server requires interactive or authenticated access")]
    AccessRestricted,
}

impl Refusal {
    /// Whether waiting and retrying could plausibly succeed.
    pub fn is_transient(self) -> bool {
        matches!(self, Refusal::RateLimited | Refusal::Unavailable)
    }
}