Skip to main content

openapp_sdk_core/
error.rs

1//! Error types surfaced by every SDK call.
2
3pub use openapp_sdk_common::ApiErrorResponse;
4use openapp_sdk_common::TokenFormatError;
5use thiserror::Error;
6
7/// Exhaustive error returned by the high-level client.
8#[derive(Debug, Error)]
9#[non_exhaustive]
10pub enum SdkError {
11    /// The API returned a non-2xx response with a JSON [`ApiErrorResponse`] body.
12    #[error("api error (status {status}): {}", .body.message)]
13    Api {
14        /// HTTP status code (e.g. 400, 404, 500).
15        status: u16,
16        /// Parsed error envelope.
17        body: ApiErrorResponse,
18    },
19
20    /// The API returned a non-2xx response whose body was not parseable JSON.
21    #[error("http {status}: {message}")]
22    Http { status: u16, message: String },
23
24    /// Authentication failed: missing, malformed, or rejected token.
25    #[error("auth error: {0}")]
26    Auth(String),
27
28    /// Token parsing failed before any request was sent.
29    #[error("invalid api key: {0}")]
30    Token(#[from] TokenFormatError),
31
32    /// Transport-level failure (DNS, TLS, connection reset, timeout…).
33    #[error("transport error: {0}")]
34    Transport(String),
35
36    /// Server reply could not be decoded (unexpected shape, invalid JSON).
37    #[error("failed to decode response: {0}")]
38    Deserialize(String),
39
40    /// Invalid SDK configuration (base URL missing, conflicting options, …).
41    #[error("invalid configuration: {0}")]
42    Config(String),
43
44    /// The caller-provided data could not be serialized into a request body.
45    #[error("failed to serialize request: {0}")]
46    Serialize(String),
47
48    /// Catch-all for anything else; always prefer a more specific variant.
49    #[error(transparent)]
50    Other(#[from] anyhow::Error),
51}
52
53impl SdkError {
54    /// `true` iff the error is safe to retry as-is.
55    #[must_use]
56    pub fn is_retryable(&self) -> bool {
57        match self {
58            Self::Transport(_) => true,
59            Self::Http { status, .. } | Self::Api { status, .. } => {
60                matches!(*status, 408 | 425 | 429 | 500 | 502 | 503 | 504)
61            }
62            _ => false,
63        }
64    }
65
66    /// HTTP status when the error carries one, else `None`.
67    #[must_use]
68    pub fn status(&self) -> Option<u16> {
69        match self {
70            Self::Api { status, .. } | Self::Http { status, .. } => Some(*status),
71            _ => None,
72        }
73    }
74}
75
76impl From<reqwest::Error> for SdkError {
77    fn from(value: reqwest::Error) -> Self {
78        if value.is_timeout() {
79            Self::Transport(format!("timeout: {value}"))
80        } else if value.is_connect() {
81            Self::Transport(format!("connect error: {value}"))
82        } else if value.is_decode() {
83            Self::Deserialize(value.to_string())
84        } else {
85            Self::Transport(value.to_string())
86        }
87    }
88}
89
90impl From<reqwest_middleware::Error> for SdkError {
91    fn from(value: reqwest_middleware::Error) -> Self {
92        match value {
93            reqwest_middleware::Error::Reqwest(err) => err.into(),
94            reqwest_middleware::Error::Middleware(err) => Self::Transport(err.to_string()),
95        }
96    }
97}
98
99impl From<serde_json::Error> for SdkError {
100    fn from(value: serde_json::Error) -> Self {
101        Self::Deserialize(value.to_string())
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn retryable_classification() {
111        assert!(SdkError::Transport("x".into()).is_retryable());
112        assert!(
113            SdkError::Http {
114                status: 503,
115                message: "x".into()
116            }
117            .is_retryable()
118        );
119        assert!(
120            !SdkError::Http {
121                status: 400,
122                message: "x".into()
123            }
124            .is_retryable()
125        );
126        assert!(!SdkError::Auth("nope".into()).is_retryable());
127    }
128
129    #[test]
130    fn status_extraction() {
131        assert_eq!(
132            SdkError::Http {
133                status: 404,
134                message: String::new()
135            }
136            .status(),
137            Some(404)
138        );
139        assert_eq!(SdkError::Auth("x".into()).status(), None);
140    }
141}