quicknode-sdk 0.8.0

Core library for quicknode sdk
Documentation
#[derive(Debug, thiserror::Error)]
pub enum SdkError {
    #[error("HTTP error: {0}")]
    Http(#[from] reqwest::Error),

    #[error("API error (status {status}): {body}")]
    Api {
        status: reqwest::StatusCode,
        body: String,
    },

    #[error("Failed to decode response: {source}\nBody: {body}")]
    Decode {
        #[source]
        source: serde_json::Error,
        body: String,
    },

    #[error("Invalid URL: {0}")]
    UrlParse(#[from] url::ParseError),

    #[error("Configuration error: {0}")]
    Config(String),

    #[error("JSON-RPC error (code {code}): {message}")]
    Rpc { code: i64, message: String },

    /// No offered payment option matched the caller's selector (pay_network +
    /// asset), or every match was skipped (over `max_amount`, unsupported
    /// `extra` shape, non-integer amount). `offered` lists what the gateway
    /// presented, for diagnosis. Not retryable without changing the selector.
    #[error("no supported payment option matched the selector; offered: {offered}")]
    PaymentUnsupported { offered: String },

    /// A signed payment was submitted and the gateway rejected it (a second
    /// 402, or a non-2xx settlement response). Terminal — the SDK will not
    /// resend. `body` carries the gateway's explanation.
    #[error("payment rejected by the gateway (status {status}): {body}")]
    PaymentRejected { status: u16, body: String },

    /// A paid request was sent but its response was lost (timeout or a
    /// transport error after the bytes may have reached the gateway). The
    /// payment MAY have settled — callers must NOT blindly retry, or they risk
    /// a double charge. Distinct from a plain `Http` error precisely so this
    /// case can be caught separately.
    #[error("payment result indeterminate: request sent but response lost — do not blindly retry (may have been charged)")]
    PaymentIndeterminate,
}

// Classifies a transport-level HTTP failure. Bindings use this to pick a
// typed exception subclass (TimeoutError / ConnectionError / HttpError) so the
// reqwest predicate logic lives in one place.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpKind {
    Timeout,
    Connect,
    Other,
}

impl SdkError {
    pub fn http_kind(&self) -> Option<HttpKind> {
        match self {
            SdkError::Http(e) if e.is_timeout() => Some(HttpKind::Timeout),
            SdkError::Http(e) if e.is_connect() => Some(HttpKind::Connect),
            SdkError::Http(_) => Some(HttpKind::Other),
            _ => None,
        }
    }
}

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

    #[test]
    fn api_error_display_includes_status_and_body() {
        let err = SdkError::Api {
            status: reqwest::StatusCode::NOT_FOUND,
            body: "not found".to_string(),
        };
        let s = err.to_string();
        assert!(s.contains("404"), "expected 404 in {s}");
        assert!(s.contains("not found"), "expected body in {s}");
    }

    #[test]
    fn config_error_display() {
        let err = SdkError::Config("missing api key".to_string());
        assert!(err.to_string().contains("missing api key"));
    }

    #[test]
    #[allow(clippy::unwrap_used)]
    fn http_kind_none_for_non_http_variants() {
        assert!(SdkError::Config("x".to_string()).http_kind().is_none());
        let decode_err = SdkError::Decode {
            source: serde_json::from_str::<i32>("bad").unwrap_err(),
            body: "bad".to_string(),
        };
        assert!(decode_err.http_kind().is_none());
    }
}