Skip to main content

quicknode_sdk/
errors.rs

1#[derive(Debug, thiserror::Error)]
2pub enum SdkError {
3    #[error("HTTP error: {0}")]
4    Http(#[from] reqwest::Error),
5
6    #[error("API error (status {status}): {body}")]
7    Api {
8        status: reqwest::StatusCode,
9        body: String,
10    },
11
12    #[error("Failed to decode response: {source}\nBody: {body}")]
13    Decode {
14        #[source]
15        source: serde_json::Error,
16        body: String,
17    },
18
19    #[error("Invalid URL: {0}")]
20    UrlParse(#[from] url::ParseError),
21
22    #[error("Configuration error: {0}")]
23    Config(String),
24
25    #[error("JSON-RPC error (code {code}): {message}")]
26    Rpc { code: i64, message: String },
27
28    /// No offered payment option matched the caller's selector (pay_network +
29    /// asset), or every match was skipped (over `max_amount`, unsupported
30    /// `extra` shape, non-integer amount). `offered` lists what the gateway
31    /// presented, for diagnosis. Not retryable without changing the selector.
32    #[error("no supported payment option matched the selector; offered: {offered}")]
33    PaymentUnsupported { offered: String },
34
35    /// A signed payment was submitted and the gateway rejected it (a second
36    /// 402, or a non-2xx settlement response). Terminal — the SDK will not
37    /// resend. `body` carries the gateway's explanation.
38    #[error("payment rejected by the gateway (status {status}): {body}")]
39    PaymentRejected { status: u16, body: String },
40
41    /// A paid request was sent but its response was lost (timeout or a
42    /// transport error after the bytes may have reached the gateway). The
43    /// payment MAY have settled — callers must NOT blindly retry, or they risk
44    /// a double charge. Distinct from a plain `Http` error precisely so this
45    /// case can be caught separately.
46    #[error("payment result indeterminate: request sent but response lost — do not blindly retry (may have been charged)")]
47    PaymentIndeterminate,
48}
49
50// Classifies a transport-level HTTP failure. Bindings use this to pick a
51// typed exception subclass (TimeoutError / ConnectionError / HttpError) so the
52// reqwest predicate logic lives in one place.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum HttpKind {
55    Timeout,
56    Connect,
57    Other,
58}
59
60impl SdkError {
61    pub fn http_kind(&self) -> Option<HttpKind> {
62        match self {
63            SdkError::Http(e) if e.is_timeout() => Some(HttpKind::Timeout),
64            SdkError::Http(e) if e.is_connect() => Some(HttpKind::Connect),
65            SdkError::Http(_) => Some(HttpKind::Other),
66            _ => None,
67        }
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn api_error_display_includes_status_and_body() {
77        let err = SdkError::Api {
78            status: reqwest::StatusCode::NOT_FOUND,
79            body: "not found".to_string(),
80        };
81        let s = err.to_string();
82        assert!(s.contains("404"), "expected 404 in {s}");
83        assert!(s.contains("not found"), "expected body in {s}");
84    }
85
86    #[test]
87    fn config_error_display() {
88        let err = SdkError::Config("missing api key".to_string());
89        assert!(err.to_string().contains("missing api key"));
90    }
91
92    #[test]
93    #[allow(clippy::unwrap_used)]
94    fn http_kind_none_for_non_http_variants() {
95        assert!(SdkError::Config("x".to_string()).http_kind().is_none());
96        let decode_err = SdkError::Decode {
97            source: serde_json::from_str::<i32>("bad").unwrap_err(),
98            body: "bad".to_string(),
99        };
100        assert!(decode_err.http_kind().is_none());
101    }
102}