cnb 0.2.0

CNB (cnb.cool) API client for Rust — typed, async, production-ready
Documentation
//! Error types returned by every API method.
//!
//! All public functions return [`Result<T>`], which is a re-export of
//! `std::result::Result<T, ApiError>`. The [`ApiError`] enum preserves enough
//! context to distinguish between transport errors, server-side error bodies,
//! and local serialisation failures.

use serde::Deserialize;

/// Crate-wide [`Result`](std::result::Result) alias.
pub type Result<T> = std::result::Result<T, ApiError>;

/// Every error surfaced by the SDK.
///
/// # Variants
///
/// * [`ApiError::Http`] — the server responded with a non-2xx status. The
///   original status code, raw response body, and (when parseable) the
///   structured `code`/`message`/`request_id` fields are all preserved so callers
///   can inspect or re-throw.
/// * [`ApiError::Reqwest`] — transport-level error from `reqwest` (DNS, TLS,
///   I/O, timeout, connect refused, …).
/// * [`ApiError::Url`] — failed to construct the request URL.
/// * [`ApiError::Json`] — local (de)serialisation failure, typically a server
///   payload that did not match the generated DTO.
/// * [`ApiError::EnvVar`] — failure to read `CNB_TOKEN` or another environment
///   variable used by the [`crate::ClientBuilder`].
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
    /// Non-2xx HTTP response. `body` is always preserved verbatim; `code`,
    /// `message` and `request_id` are best-effort extracted from JSON bodies.
    #[error("HTTP {status}: {message}")]
    Http {
        /// HTTP status code returned by the server.
        status: u16,
        /// Raw response body bytes decoded as UTF-8 (lossy if non-UTF-8).
        body: String,
        /// Server-defined error code (e.g. `"NotFound"`), when the response is
        /// JSON and contains a `code` / `error_code` field.
        code: Option<String>,
        /// Human-readable error message — server-provided when available,
        /// otherwise a generic `<reason phrase>` derived from the status code.
        message: String,
        /// `X-Request-Id` header (or `request_id` body field) for support
        /// triage. `None` when neither is present.
        request_id: Option<String>,
    },

    /// Underlying transport failure from `reqwest` (DNS, TLS, timeout, …).
    #[error(transparent)]
    Reqwest(#[from] reqwest::Error),

    /// URL parse error (almost always indicates a programming bug rather than
    /// user input).
    #[error(transparent)]
    Url(#[from] url::ParseError),

    /// Local JSON (de)serialisation failure.
    #[error(transparent)]
    Json(#[from] serde_json::Error),

    /// Environment variable lookup failure (e.g. invalid UTF-8 in `CNB_TOKEN`).
    #[error("environment variable error: {0}")]
    EnvVar(String),
}

impl ApiError {
    /// Convenience accessor for the HTTP status code, if any. Returns `None`
    /// for non-HTTP variants (e.g. transport errors).
    pub fn status(&self) -> Option<u16> {
        match self {
            ApiError::Http { status, .. } => Some(*status),
            ApiError::Reqwest(e) => e.status().map(|s| s.as_u16()),
            _ => None,
        }
    }

    /// `true` when the error very likely will succeed if retried (5xx, 408,
    /// 429, transport errors). The default retry layer uses this to decide
    /// whether to back off and retry idempotent requests.
    pub fn is_retryable(&self) -> bool {
        match self {
            ApiError::Http { status, .. } => {
                matches!(*status, 408 | 429) || (*status >= 500 && *status < 600)
            }
            ApiError::Reqwest(e) => e.is_timeout() || e.is_connect() || e.is_request(),
            _ => false,
        }
    }
}

/// Best-effort representation of a CNB error envelope. Multiple alternative
/// field names are accepted because the upstream spec does not pin one.
#[derive(Debug, Default, Deserialize)]
pub(crate) struct ErrorEnvelope {
    #[serde(default, alias = "error_code", alias = "errorCode")]
    pub code: Option<String>,
    #[serde(default, alias = "msg", alias = "error", alias = "error_message")]
    pub message: Option<String>,
    #[serde(default, alias = "requestId", alias = "request-id")]
    pub request_id: Option<String>,
}

impl ErrorEnvelope {
    /// Try to parse a JSON response body into structured fields. Returns
    /// `Default::default()` (all `None`) on any parse failure — we never let
    /// error reporting itself fail.
    pub fn parse(body: &str) -> Self {
        serde_json::from_str(body).unwrap_or_default()
    }
}