opencellid 0.2.0

Rust client library for the OpenCellID API — sync and async clients with tracing, structured errors, and bounded I/O.
Documentation
//! Error types for the OpenCellID client.

use std::fmt;

/// Crate result alias.
pub type Result<T> = std::result::Result<T, Error>;

/// Boxed inner cause attached to opaque error wrappers.
type BoxedCause = Box<dyn std::error::Error + Send + Sync + 'static>;

/// Maximum number of bytes from a server response body included in an error message.
const ERROR_BODY_LIMIT: usize = 512;

/// All errors that can be produced by the client.
///
/// `Error` does not expose the concrete error types of underlying crates such as
/// `reqwest`, `url`, `serde_json` or `csv`; their breaking changes do not become
/// breaking changes of this crate. The original cause is preserved on
/// [`std::error::Error::source`] (and on the inherent `source()` method of each
/// wrapper) when one is available.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// HTTP transport failure (DNS, TLS, connect, timeout, body read).
    #[error("HTTP transport error: {0}")]
    Transport(#[source] TransportError),

    /// Failed to build a request URL.
    #[error("URL error: {0}")]
    Url(#[source] UrlError),

    /// Response could not be parsed in the expected format.
    #[error("response parse error: {0}")]
    Parse(#[source] ParseError),

    /// OpenCellID returned a structured error in the response body or via HTTP status.
    #[error("OpenCellID API error ({code}): {message}")]
    Api {
        /// Mapped error code.
        code: ApiErrorCode,
        /// Human-readable, length-bounded message returned by the API or synthesised
        /// from an HTTP status. Long bodies are truncated; control characters are
        /// escaped to make logs safe to print.
        message: String,
    },

    /// Caller-supplied input was invalid before any network call was made.
    #[error("invalid input: {0}")]
    InvalidInput(String),

    /// Required configuration was missing on the [`crate::ClientBuilder`].
    #[error("missing configuration: {0}")]
    MissingConfig(&'static str),
}

/// Opaque wrapper for transport-layer errors.
///
/// Use [`std::error::Error::source`] to inspect the underlying cause.
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
#[non_exhaustive]
pub struct TransportError {
    message: String,
    #[source]
    source: Option<BoxedCause>,
}

impl TransportError {
    pub(crate) fn new(err: reqwest::Error) -> Self {
        Self {
            message: err.to_string(),
            source: Some(Box::new(err)),
        }
    }
}

impl From<reqwest::Error> for Error {
    fn from(err: reqwest::Error) -> Self {
        Error::Transport(TransportError::new(err))
    }
}

/// Opaque wrapper for URL-construction errors.
///
/// Use [`std::error::Error::source`] to inspect the underlying cause.
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
#[non_exhaustive]
pub struct UrlError {
    message: String,
    #[source]
    source: Option<BoxedCause>,
}

impl UrlError {
    pub(crate) fn new(err: url::ParseError) -> Self {
        Self {
            message: err.to_string(),
            source: Some(Box::new(err)),
        }
    }
}

impl From<url::ParseError> for Error {
    fn from(err: url::ParseError) -> Self {
        Error::Url(UrlError::new(err))
    }
}

/// Opaque wrapper for parse-layer errors (JSON, CSV, ...).
///
/// Use [`std::error::Error::source`] to inspect the underlying cause.
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
#[non_exhaustive]
pub struct ParseError {
    message: String,
    #[source]
    source: Option<BoxedCause>,
}

impl ParseError {
    /// Build a parse error with no inner cause.
    pub(crate) fn new(message: impl Into<String>) -> Self {
        Self { message: message.into(), source: None }
    }

    /// Build a parse error wrapping an inner cause.
    pub(crate) fn with_source(
        message: impl Into<String>,
        source: impl std::error::Error + Send + Sync + 'static,
    ) -> Self {
        Self { message: message.into(), source: Some(Box::new(source)) }
    }
}

/// Documented OpenCellID API error codes.
///
/// See <https://wiki.opencellid.org/wiki/API#Error_Codes>.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ApiErrorCode {
    /// `1` — cell not found in the database.
    CellNotFound,
    /// `2` — invalid or unknown API key.
    InvalidApiKey,
    /// `3` — invalid input data.
    InvalidInput,
    /// `4` — API key needs whitelisting for commercial use.
    NeedsWhitelisting,
    /// `5` — internal server error.
    ServerError,
    /// `6` — too many requests, retry later.
    TooManyRequests,
    /// `7` — daily request limit exceeded.
    DailyLimitExceeded,
    /// Any other code returned by the API or synthesised from an HTTP status.
    ///
    /// External code can match `Unknown(_)` but cannot construct this variant
    /// directly — use [`ApiErrorCode::from_code`] so that newly-named variants
    /// stay forward-compatible.
    #[non_exhaustive]
    Unknown(u16),
}

impl ApiErrorCode {
    /// Map a documented numeric code to a variant.
    pub fn from_code(code: u16) -> Self {
        match code {
            1 => Self::CellNotFound,
            2 => Self::InvalidApiKey,
            3 => Self::InvalidInput,
            4 => Self::NeedsWhitelisting,
            5 => Self::ServerError,
            6 => Self::TooManyRequests,
            7 => Self::DailyLimitExceeded,
            other => Self::Unknown(other),
        }
    }

    /// True if the error suggests a retry might succeed (rate limits, transient server errors).
    ///
    /// Callers may use this to drive retry/backoff middleware around the client; this
    /// crate itself never retries.
    pub fn is_retryable(self) -> bool {
        matches!(self, Self::ServerError | Self::TooManyRequests)
    }
}

impl fmt::Display for ApiErrorCode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::CellNotFound => f.write_str("cell_not_found"),
            Self::InvalidApiKey => f.write_str("invalid_api_key"),
            Self::InvalidInput => f.write_str("invalid_input"),
            Self::NeedsWhitelisting => f.write_str("needs_whitelisting"),
            Self::ServerError => f.write_str("server_error"),
            Self::TooManyRequests => f.write_str("too_many_requests"),
            Self::DailyLimitExceeded => f.write_str("daily_limit_exceeded"),
            Self::Unknown(c) => write!(f, "unknown({c})"),
        }
    }
}

/// Truncate and escape a server response body for inclusion in error messages and logs.
///
/// Limits length to [`ERROR_BODY_LIMIT`] bytes and escapes control characters so that
/// hostile or accidental newlines can't forge log lines.
pub(crate) fn truncate_for_diagnostic(body: &str) -> String {
    if body.is_empty() {
        return String::new();
    }
    let total = body.len();
    let limit = ERROR_BODY_LIMIT;
    let prefix = if total > limit {
        // char_indices avoids slicing across UTF-8 boundaries.
        let cut = body
            .char_indices()
            .map(|(i, _)| i)
            .take_while(|&i| i < limit)
            .last()
            .unwrap_or(0);
        &body[..cut]
    } else {
        body
    };
    let mut out: String = prefix.escape_debug().collect();
    if total > limit {
        use std::fmt::Write as _;
        // Writing to a String never fails.
        let _ = write!(out, "…({total} bytes total)");
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn api_error_code_round_trip() {
        let cases = [
            (1u16, ApiErrorCode::CellNotFound, false),
            (2, ApiErrorCode::InvalidApiKey, false),
            (3, ApiErrorCode::InvalidInput, false),
            (4, ApiErrorCode::NeedsWhitelisting, false),
            (5, ApiErrorCode::ServerError, true),
            (6, ApiErrorCode::TooManyRequests, true),
            (7, ApiErrorCode::DailyLimitExceeded, false),
            (99, ApiErrorCode::Unknown(99), false),
        ];
        for (code, expected, retryable) in cases {
            let got = ApiErrorCode::from_code(code);
            assert_eq!(got, expected, "from_code({code})");
            assert_eq!(got.is_retryable(), retryable, "is_retryable for {code}");
        }
    }

    #[test]
    fn api_error_code_display_is_stable() {
        assert_eq!(ApiErrorCode::CellNotFound.to_string(), "cell_not_found");
        assert_eq!(ApiErrorCode::Unknown(42).to_string(), "unknown(42)");
    }

    #[test]
    fn truncate_caps_length() {
        let body = "a".repeat(2000);
        let out = truncate_for_diagnostic(&body);
        assert!(out.len() < body.len());
        assert!(out.ends_with("(2000 bytes total)"));
    }

    #[test]
    fn truncate_escapes_newlines() {
        let body = "ok\nINJECT level=ERROR";
        let out = truncate_for_diagnostic(body);
        assert!(!out.contains('\n'));
        assert!(out.contains("\\n"));
    }

    #[test]
    fn truncate_passes_short_body_through() {
        let body = "short";
        assert_eq!(truncate_for_diagnostic(body), "short");
    }

    #[test]
    fn parse_error_chain_via_std_error_source() {
        use std::error::Error as _;
        let inner = std::io::Error::other("disk gone");
        let pe = ParseError::with_source("response read", inner);
        assert!(pe.source().is_some());

        let none = ParseError::new("bare");
        assert!(none.source().is_none());
    }
}