dregs 1.0.0

The official Rust client for Dregs, which scores the users of your application for fraud and abuse.
Documentation
//! Errors returned by the Dregs SDK.
//!
//! Every fallible call in this crate returns [`Error`], so a caller that only wants a coarse "the
//! Dregs call failed" branch can treat it as one type. Errors that came back from the API carry
//! an [`ApiError`] with the HTTP status and the parsed body; errors that never reached the API
//! (connection refused, DNS failure, timeout) are [`Error::Connection`] and [`Error::Timeout`]
//! instead.

use std::fmt;
use std::time::Duration;

use serde_json::Value;

/// A `Result` whose error is this crate's [`Error`].
pub type Result<T, E = Error> = std::result::Result<T, E>;

/// Everything this crate returns as an error.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// The call was refused before any request was made, because Dregs would refuse it too: a
    /// missing secret key, an empty identity, a malformed group, and the like. The message says
    /// what to change.
    #[error("{0}")]
    InvalidArgument(String),

    /// Dregs answered, and the answer was an error.
    #[error(transparent)]
    Api(#[from] ApiError),

    /// The request was still outstanding when the configured timeout elapsed.
    #[error("Request to {url} timed out.")]
    Timeout {
        /// The URL that was being requested.
        url: String,
        /// The underlying HTTP client error.
        #[source]
        source: reqwest::Error,
    },

    /// The request never reached Dregs: DNS, TCP, TLS, or a dropped connection.
    #[error("Could not reach Dregs at {url}: {source}")]
    Connection {
        /// The URL that was being requested.
        url: String,
        /// The underlying HTTP client error.
        #[source]
        source: reqwest::Error,
    },

    /// An incoming webhook did not verify against the channel's signing secret.
    #[error("{0}")]
    WebhookVerification(String),
}

impl Error {
    /// The [`ApiError`] behind this error, when Dregs answered with one.
    pub fn api_error(&self) -> Option<&ApiError> {
        match self {
            Error::Api(error) => Some(error),
            _ => None,
        }
    }

    /// What kind of API error this is, when Dregs answered with one.
    ///
    /// ```
    /// # fn handle(error: dregs::Error) {
    /// use dregs::ApiErrorKind;
    ///
    /// match error.kind() {
    ///     Some(ApiErrorKind::QuotaExceeded) => { /* over the monthly event limit */ }
    ///     Some(ApiErrorKind::RateLimited) => { /* ingesting too fast */ }
    ///     _ => { /* anything else */ }
    /// }
    /// # }
    /// ```
    pub fn kind(&self) -> Option<ApiErrorKind> {
        self.api_error().map(|error| error.kind)
    }

    /// The HTTP status, when Dregs answered with an error.
    pub fn status(&self) -> Option<u16> {
        self.api_error().map(|error| error.status)
    }
}

/// Which error Dregs answered with, by HTTP status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ApiErrorKind {
    /// 400. The request was malformed or missing something Dregs requires.
    ///
    /// For event ingestion this most often means the event carried neither an identity nor a
    /// device, or the body failed validation.
    BadRequest,

    /// 401. The secret key was missing, unrecognized, revoked, or expired.
    Authentication,

    /// 402. The account is over its monthly event limit and ingestion is refused.
    ///
    /// Events are not queued while an account is over its limit, so the caller decides whether
    /// to drop the event or hold it. The limit resets with the billing period; upgrading the plan
    /// clears it immediately.
    QuotaExceeded,

    /// 403. The credential authenticated but is not allowed to do this.
    PermissionDenied,

    /// 404. No such identity, or no analysis has been run for it yet.
    NotFound,

    /// 429. The credential exceeded its request rate limit. See [`ApiError::retry_after`].
    RateLimited,

    /// 5xx. Something went wrong inside Dregs. These are retried automatically.
    Server,

    /// Any other error status.
    Other,
}

impl ApiErrorKind {
    /// The kind that represents `status`.
    pub fn from_status(status: u16) -> Self {
        match status {
            400 => ApiErrorKind::BadRequest,
            401 => ApiErrorKind::Authentication,
            402 => ApiErrorKind::QuotaExceeded,
            403 => ApiErrorKind::PermissionDenied,
            404 => ApiErrorKind::NotFound,
            429 => ApiErrorKind::RateLimited,
            500.. => ApiErrorKind::Server,
            _ => ApiErrorKind::Other,
        }
    }
}

/// Dregs answered, and the answer was an error.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ApiError {
    /// Which error this is, by status.
    pub kind: ApiErrorKind,
    /// The HTTP status code.
    pub status: u16,
    /// The human-readable message, taken from the response body when it carries one.
    pub message: String,
    /// The parsed JSON body, or `None` when the response was not JSON.
    pub body: Option<Value>,
    /// Value of the `X-Request-Id` response header, when present.
    pub request_id: Option<String>,
    /// How long to wait before retrying, from the `Retry-After` header of a rate-limited
    /// response that carried one.
    pub retry_after: Option<Duration>,
}

impl fmt::Display for ApiError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "HTTP {}: {}", self.status, self.message)?;

        if let Some(request_id) = &self.request_id {
            write!(f, " (request {request_id})")?;
        }

        Ok(())
    }
}

impl std::error::Error for ApiError {}