invoance 0.2.0

Official Rust SDK for the Invoance compliance API
Documentation
//! SDK error hierarchy.
//!
//! Every error raised by the SDK — API responses, network failures, and
//! client-side validation — is a variant of [`Error`], so a single match (or
//! the boolean predicates below) is enough to classify anything the SDK
//! produces.
//!
//! ```no_run
//! # use invoance::Error;
//! # fn handle(e: Error) {
//! if e.is_authentication() {
//!     // 401 — bad API key
//! } else if e.is_quota_exceeded() {
//!     println!("rate limited, retry in {:?}s", e.retry_after_seconds());
//! }
//! # }
//! ```

use std::collections::BTreeMap;

use serde_json::Value;

/// Request context attached to an [`Error`] (HTTP method + path).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestContext {
    pub method: String,
    pub path: String,
}

/// The kind of an API-level error, derived from the HTTP status code.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiErrorKind {
    /// 401
    Authentication,
    /// 403
    Forbidden,
    /// 404
    NotFound,
    /// 400 (also used for client-side validation before sending)
    Validation,
    /// 409
    Conflict,
    /// 429
    QuotaExceeded,
    /// 5xx
    Server,
    /// Any other non-2xx status the map does not cover.
    Api,
}

/// The payload of an [`Error::Api`] variant.
///
/// Boxed inside the enum to keep [`Error`] small (it appears in every
/// `Result` the SDK returns).
#[derive(Debug, Clone)]
pub struct ApiError {
    pub kind: ApiErrorKind,
    pub message: String,
    pub status_code: Option<u16>,
    pub error_code: Option<String>,
    pub body: Option<BTreeMap<String, Value>>,
    pub request: Option<RequestContext>,
    pub retry_after_seconds: Option<f64>,
}

/// The unified error type for every fallible SDK operation.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// An error returned by the Invoance API (any non-2xx response), or a
    /// client-side validation failure (kind [`ApiErrorKind::Validation`]).
    #[error("{}", .0.message)]
    Api(Box<ApiError>),

    /// The request failed before a response was received (DNS, connection,
    /// TLS handshake, etc.).
    #[error("{message}")]
    Network {
        message: String,
        request: Option<RequestContext>,
        #[source]
        source: Option<Box<dyn std::error::Error + Send + Sync>>,
    },

    /// The request exceeded the configured timeout.
    #[error("{message}")]
    Timeout {
        message: String,
        request: Option<RequestContext>,
        #[source]
        source: Option<Box<dyn std::error::Error + Send + Sync>>,
    },
}

impl Error {
    // ── Constructors ──────────────────────────────────────────

    /// Build a client-side [`ApiErrorKind::Validation`] error.
    pub(crate) fn validation(message: impl Into<String>) -> Self {
        Error::Api(Box::new(ApiError {
            kind: ApiErrorKind::Validation,
            message: message.into(),
            status_code: None,
            error_code: None,
            body: None,
            request: None,
            retry_after_seconds: None,
        }))
    }

    pub(crate) fn network(
        message: impl Into<String>,
        request: Option<RequestContext>,
        source: Option<Box<dyn std::error::Error + Send + Sync>>,
    ) -> Self {
        Error::Network {
            message: message.into(),
            request,
            source,
        }
    }

    pub(crate) fn timeout(
        message: impl Into<String>,
        request: Option<RequestContext>,
        source: Option<Box<dyn std::error::Error + Send + Sync>>,
    ) -> Self {
        Error::Timeout {
            message: message.into(),
            request,
            source,
        }
    }

    // ── Accessors ─────────────────────────────────────────────

    /// The HTTP status code, if this error came from a server response.
    pub fn status_code(&self) -> Option<u16> {
        match self {
            Error::Api(a) => a.status_code,
            _ => None,
        }
    }

    /// The machine-readable `error` code from the response body, if any.
    pub fn error_code(&self) -> Option<&str> {
        match self {
            Error::Api(a) => a.error_code.as_deref(),
            _ => None,
        }
    }

    /// Parsed `Retry-After`, in seconds (present on 429 responses).
    pub fn retry_after_seconds(&self) -> Option<f64> {
        match self {
            Error::Api(a) => a.retry_after_seconds,
            _ => None,
        }
    }

    /// The parsed JSON response body, if any.
    pub fn body(&self) -> Option<&BTreeMap<String, Value>> {
        match self {
            Error::Api(a) => a.body.as_ref(),
            _ => None,
        }
    }

    /// The request context (method + path) associated with this error.
    pub fn request(&self) -> Option<&RequestContext> {
        match self {
            Error::Api(a) => a.request.as_ref(),
            Error::Network { request, .. } | Error::Timeout { request, .. } => request.as_ref(),
        }
    }

    /// The [`ApiErrorKind`] for an API error, or `None` for transport errors.
    pub fn kind(&self) -> Option<ApiErrorKind> {
        match self {
            Error::Api(a) => Some(a.kind),
            _ => None,
        }
    }

    // ── Predicates ────────────────────────────────────────────

    fn is_kind(&self, k: ApiErrorKind) -> bool {
        matches!(self, Error::Api(a) if a.kind == k)
    }

    /// True for 401 responses.
    pub fn is_authentication(&self) -> bool {
        self.is_kind(ApiErrorKind::Authentication)
    }
    /// True for 403 responses.
    pub fn is_forbidden(&self) -> bool {
        self.is_kind(ApiErrorKind::Forbidden)
    }
    /// True for 404 responses.
    pub fn is_not_found(&self) -> bool {
        self.is_kind(ApiErrorKind::NotFound)
    }
    /// True for 400 responses and client-side validation failures.
    pub fn is_validation(&self) -> bool {
        self.is_kind(ApiErrorKind::Validation)
    }
    /// True for 409 responses.
    pub fn is_conflict(&self) -> bool {
        self.is_kind(ApiErrorKind::Conflict)
    }
    /// True for 429 responses.
    pub fn is_quota_exceeded(&self) -> bool {
        self.is_kind(ApiErrorKind::QuotaExceeded)
    }
    /// True for 5xx responses.
    pub fn is_server(&self) -> bool {
        self.is_kind(ApiErrorKind::Server)
    }
    /// True for transport failures before a response was received.
    pub fn is_network(&self) -> bool {
        matches!(self, Error::Network { .. })
    }
    /// True when the request exceeded the configured timeout.
    pub fn is_timeout(&self) -> bool {
        matches!(self, Error::Timeout { .. })
    }
}

fn kind_for_status(status: u16) -> ApiErrorKind {
    match status {
        400 => ApiErrorKind::Validation,
        401 => ApiErrorKind::Authentication,
        403 => ApiErrorKind::Forbidden,
        404 => ApiErrorKind::NotFound,
        409 => ApiErrorKind::Conflict,
        429 => ApiErrorKind::QuotaExceeded,
        s if s >= 500 => ApiErrorKind::Server,
        _ => ApiErrorKind::Api,
    }
}

fn describe_request(ctx: Option<&RequestContext>) -> String {
    match ctx {
        Some(c) => format!(" on {} {}", c.method, c.path),
        None => String::new(),
    }
}

/// Convert an HTTP status + body into a [`Result`]: `Ok(())` on 2xx, otherwise
/// the classified [`Error`]. Mirrors the reference SDK's `throwForStatus`.
pub(crate) fn error_for_status(
    status_code: u16,
    body: Option<BTreeMap<String, Value>>,
    request: Option<RequestContext>,
    retry_after_seconds: Option<f64>,
) -> Result<(), Error> {
    if (200..300).contains(&status_code) {
        return Ok(());
    }

    let b = body.clone().unwrap_or_default();
    let error_code = b
        .get("error")
        .and_then(Value::as_str)
        .map(str::to_string)
        .unwrap_or_else(|| "unknown".to_string());
    let server_message = b.get("message").and_then(Value::as_str).map(str::to_string);

    let message = if let Some(m) = server_message {
        m
    } else if let (429, Some(secs)) = (status_code, retry_after_seconds) {
        format!(
            "HTTP 429{} — rate limited, retry after {}s",
            describe_request(request.as_ref()),
            secs
        )
    } else {
        format!(
            "HTTP {}{} (no response body)",
            status_code,
            describe_request(request.as_ref())
        )
    };

    Err(Error::Api(Box::new(ApiError {
        kind: kind_for_status(status_code),
        message,
        status_code: Some(status_code),
        error_code: Some(error_code),
        body: Some(b),
        request,
        retry_after_seconds,
    })))
}