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    /// No resource matched the requested localized name.
49    #[error("no {resource_type} named {name:?}")]
50    ResourceNotFound {
51        /// Resource kind (e.g. `"integration"`, `"portal"`).
52        resource_type: &'static str,
53        /// Name needle passed to `get_by_name`.
54        name: String,
55    },
56
57    /// Multiple resources matched the same localized name.
58    #[error("{resource_type} name {name:?} is ambiguous ({match_count} matches)")]
59    AmbiguousResource {
60        /// Resource kind (e.g. `"integration"`, `"portal"`).
61        resource_type: &'static str,
62        /// Name needle passed to `get_by_name`.
63        name: String,
64        /// All matching resource objects (include `id` for disambiguation).
65        matches: Vec<serde_json::Value>,
66        /// Number of matches (convenience for bindings).
67        match_count: usize,
68    },
69
70    /// Catch-all for anything else; always prefer a more specific variant.
71    #[error(transparent)]
72    Other(#[from] anyhow::Error),
73}
74
75impl SdkError {
76    /// `true` iff the error is safe to retry as-is.
77    #[must_use]
78    pub fn is_retryable(&self) -> bool {
79        match self {
80            Self::Transport(_) => true,
81            Self::Http { status, .. } | Self::Api { status, .. } => {
82                matches!(*status, 408 | 425 | 429 | 500 | 502 | 503 | 504)
83            }
84            _ => false,
85        }
86    }
87
88    /// HTTP status when the error carries one, else `None`.
89    #[must_use]
90    pub fn status(&self) -> Option<u16> {
91        match self {
92            Self::Api { status, .. } | Self::Http { status, .. } => Some(*status),
93            _ => None,
94        }
95    }
96}
97
98impl From<reqwest::Error> for SdkError {
99    fn from(value: reqwest::Error) -> Self {
100        if value.is_timeout() {
101            Self::Transport(format!("timeout: {value}"))
102        } else if value.is_connect() {
103            Self::Transport(format!("connect error: {value}"))
104        } else if value.is_decode() {
105            Self::Deserialize(value.to_string())
106        } else {
107            Self::Transport(value.to_string())
108        }
109    }
110}
111
112impl From<reqwest_middleware::Error> for SdkError {
113    fn from(value: reqwest_middleware::Error) -> Self {
114        match value {
115            reqwest_middleware::Error::Reqwest(err) => err.into(),
116            reqwest_middleware::Error::Middleware(err) => Self::Transport(err.to_string()),
117        }
118    }
119}
120
121impl From<serde_json::Error> for SdkError {
122    fn from(value: serde_json::Error) -> Self {
123        Self::Deserialize(value.to_string())
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn retryable_classification() {
133        assert!(SdkError::Transport("x".into()).is_retryable());
134        assert!(
135            SdkError::Http {
136                status: 503,
137                message: "x".into()
138            }
139            .is_retryable()
140        );
141        assert!(
142            !SdkError::Http {
143                status: 400,
144                message: "x".into()
145            }
146            .is_retryable()
147        );
148        assert!(!SdkError::Auth("nope".into()).is_retryable());
149    }
150
151    #[test]
152    fn status_extraction() {
153        assert_eq!(
154            SdkError::Http {
155                status: 404,
156                message: String::new()
157            }
158            .status(),
159            Some(404)
160        );
161        assert_eq!(SdkError::Auth("x".into()).status(), None);
162    }
163}