snippe 0.1.0

Async Rust client for the Snippe payments API (Tanzania) — collections, hosted checkout sessions, disbursements, and verified webhooks.
Documentation
//! Error types returned by the SDK.

use serde::Deserialize;

/// All errors returned by SDK operations.
///
/// API errors (4xx / 5xx with a structured body) come through as
/// [`Error::Api`] carrying an [`ApiError`] with the status code, the stable
/// [`ErrorCode`] from Snippe, and the human-readable message. Use
/// [`ApiError::is_retryable`] for retry decisions.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// HTTP transport, DNS, TLS, or connect-level failure.
    #[error("HTTP transport error: {0}")]
    Http(#[source] reqwest::Error),

    /// Failed to decode the response body as JSON.
    #[error("failed to decode response: {0}")]
    Decode(#[source] serde_json::Error),

    /// Failed to encode a request body as JSON.
    #[error("failed to encode request: {0}")]
    Encode(#[source] serde_json::Error),

    /// Request exceeded the configured timeout.
    #[error("request timed out")]
    Timeout,

    /// Server returned a non-2xx response.
    #[error("API error: {0}")]
    Api(ApiError),

    /// Client builder was given invalid configuration (e.g. missing API key).
    #[error("client configuration error: {0}")]
    Config(String),

    /// An idempotency key violated the length / non-empty constraint.
    #[error(transparent)]
    InvalidIdempotencyKey(#[from] crate::idempotency::IdempotencyKeyError),

    /// Webhook signature verification or parsing failed.
    #[error(transparent)]
    Webhook(#[from] crate::webhook::WebhookError),
}

impl From<reqwest::Error> for Error {
    fn from(e: reqwest::Error) -> Self {
        if e.is_timeout() {
            Self::Timeout
        } else {
            Self::Http(e)
        }
    }
}

/// Structured information from a non-2xx Snippe response.
///
/// The Snippe response envelope on error looks like:
///
/// ```json
/// {
///   "status": "error",
///   "code": 400,
///   "error_code": "validation_error",
///   "message": "amount is required"
/// }
/// ```
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ApiError {
    /// HTTP status code.
    pub status: u16,
    /// Stable machine-readable Snippe error code (e.g. `validation_error`).
    pub error_code: ErrorCode,
    /// Human-readable error message.
    pub message: String,
    /// Value of the `X-Ratelimit-Reset` header in seconds, if the response was
    /// a 429 rate limit.
    pub retry_after: Option<u64>,
}

impl ApiError {
    /// True if this error is worth retrying with backoff.
    ///
    /// - `5xx` and the `PAY_001` code are retryable (transient processor /
    ///   server issues).
    /// - `429 rate_limit_exceeded` is retryable, but you should wait until
    ///   [`Self::retry_after`] seconds before the next attempt.
    /// - All `4xx` validation, auth, and conflict errors are **not** retryable.
    pub fn is_retryable(&self) -> bool {
        if self.status >= 500 {
            return true;
        }
        matches!(
            self.error_code,
            ErrorCode::RateLimitExceeded | ErrorCode::Pay001
        )
    }

    /// Convenience: true if this is a 401 unauthorized.
    pub fn is_unauthorized(&self) -> bool {
        self.status == 401 || matches!(self.error_code, ErrorCode::Unauthorized)
    }

    /// Convenience: true if this is a 422 idempotency-conflict error.
    pub fn is_idempotency_conflict(&self) -> bool {
        self.status == 422
    }
}

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

/// Stable Snippe error codes returned in `error_code`.
///
/// Use this for programmatic dispatch instead of matching on the human-readable
/// `message` (which can change without warning).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ErrorCode {
    /// Invalid or missing API key.
    Unauthorized,
    /// API key authenticated but lacks the scope the endpoint requires.
    InsufficientScope,
    /// One or more fields in the request are invalid.
    ValidationError,
    /// Resource doesn't exist.
    NotFound,
    /// Resource state conflict (e.g. cancelling a completed session).
    Conflict,
    /// Payment processor failure — includes "insufficient balance".
    PaymentFailed,
    /// Rate limit exceeded — read [`ApiError::retry_after`] before retrying.
    RateLimitExceeded,
    /// `PAY_001` — failed to initiate payment. Most common cause: the
    /// `Idempotency-Key` exceeded 30 characters. Second cause: upstream
    /// processor (Selcom) is temporarily unavailable.
    Pay001,
    /// An unrecognised error code carried as the raw string. Useful for
    /// forward compatibility — new server-side codes won't fail to parse.
    Other(String),
}

impl ErrorCode {
    /// Borrow the on-the-wire string representation.
    pub fn as_str(&self) -> &str {
        match self {
            Self::Unauthorized => "unauthorized",
            Self::InsufficientScope => "insufficient_scope",
            Self::ValidationError => "validation_error",
            Self::NotFound => "not_found",
            Self::Conflict => "conflict",
            Self::PaymentFailed => "payment_failed",
            Self::RateLimitExceeded => "rate_limit_exceeded",
            Self::Pay001 => "PAY_001",
            Self::Other(s) => s.as_str(),
        }
    }

    pub(crate) fn from_str(s: &str) -> Self {
        match s {
            "unauthorized" => Self::Unauthorized,
            "insufficient_scope" => Self::InsufficientScope,
            "validation_error" => Self::ValidationError,
            "not_found" => Self::NotFound,
            "conflict" => Self::Conflict,
            "payment_failed" => Self::PaymentFailed,
            "rate_limit_exceeded" => Self::RateLimitExceeded,
            "PAY_001" => Self::Pay001,
            other => Self::Other(other.to_string()),
        }
    }
}

impl<'de> Deserialize<'de> for ErrorCode {
    fn deserialize<D>(d: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let s = String::deserialize(d)?;
        Ok(Self::from_str(&s))
    }
}

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

    #[test]
    fn known_codes_round_trip() {
        for s in [
            "unauthorized",
            "insufficient_scope",
            "validation_error",
            "not_found",
            "conflict",
            "payment_failed",
            "rate_limit_exceeded",
            "PAY_001",
        ] {
            let code = ErrorCode::from_str(s);
            assert_eq!(code.as_str(), s);
        }
    }

    #[test]
    fn unknown_codes_preserve_raw_string() {
        let code = ErrorCode::from_str("future_error_code");
        assert!(matches!(code, ErrorCode::Other(_)));
        assert_eq!(code.as_str(), "future_error_code");
    }

    #[test]
    fn pay001_is_retryable() {
        let err = ApiError {
            status: 500,
            error_code: ErrorCode::Pay001,
            message: "failed to initiate payment".into(),
            retry_after: None,
        };
        assert!(err.is_retryable());
    }

    #[test]
    fn validation_is_not_retryable() {
        let err = ApiError {
            status: 400,
            error_code: ErrorCode::ValidationError,
            message: "amount is required".into(),
            retry_after: None,
        };
        assert!(!err.is_retryable());
    }

    #[test]
    fn rate_limited_is_retryable() {
        let err = ApiError {
            status: 429,
            error_code: ErrorCode::RateLimitExceeded,
            message: "Too many requests".into(),
            retry_after: Some(15),
        };
        assert!(err.is_retryable());
    }
}