Skip to main content

cirrus_metadata/
error.rs

1//! Error types for the `cirrus-metadata` SDK.
2//!
3//! The Metadata API speaks SOAP 1.1, so its error shape is different from the
4//! REST surface modeled by `cirrus`. Failures arrive as `<soapenv:Fault>`
5//! elements with a `faultcode` (e.g. `sf:INVALID_SESSION_ID`,
6//! `sf:INVALID_TYPE`) and a `faultstring` description. [`SoapFault`] models
7//! that shape and [`MetadataError::Soap`] carries it alongside the HTTP
8//! status.
9//!
10//! Auth-flow errors come from [`cirrus_auth`] as [`AuthError`] and are
11//! wrapped by [`MetadataError::Auth`]; the `From` impl lets handlers
12//! propagate them via `?`.
13
14use cirrus_auth::AuthError;
15use thiserror::Error;
16
17/// Specialized `Result` type for `cirrus-metadata` operations.
18pub type MetadataResult<T> = Result<T, MetadataError>;
19
20/// A parsed SOAP 1.1 `<Fault>` element.
21///
22/// Salesforce returns faults with a colon-qualified `faultcode` whose local
23/// part is the canonical error code (e.g. `sf:INVALID_SESSION_ID` →
24/// `INVALID_SESSION_ID`). [`Self::code`] returns that local part with the
25/// prefix stripped, which is what callers usually want to match on.
26#[derive(Debug, Clone)]
27pub struct SoapFault {
28    /// Full `faultcode` value as it appeared on the wire, e.g.
29    /// `sf:INVALID_SESSION_ID`.
30    pub faultcode: String,
31    /// Human-readable `faultstring`.
32    pub faultstring: String,
33}
34
35impl SoapFault {
36    /// Returns the local part of [`faultcode`](Self::faultcode) — the
37    /// substring after the last `:`. For `sf:INVALID_SESSION_ID` this is
38    /// `INVALID_SESSION_ID`. If the faultcode has no `:` the full string is
39    /// returned.
40    pub fn code(&self) -> &str {
41        self.faultcode
42            .rsplit_once(':')
43            .map(|(_, local)| local)
44            .unwrap_or(&self.faultcode)
45    }
46
47    /// True if this fault represents an expired or invalid session,
48    /// triggering the SDK's auth-retry path.
49    pub(crate) fn is_invalid_session(&self) -> bool {
50        self.code() == "INVALID_SESSION_ID"
51    }
52}
53
54/// Errors produced by the `cirrus-metadata` client.
55#[derive(Debug, Error)]
56pub enum MetadataError {
57    /// A required builder field was not set.
58    #[error("missing required builder field: {0}")]
59    MissingField(&'static str),
60
61    /// Failed to construct the underlying HTTP client.
62    #[error("failed to construct HTTP client: {0}")]
63    HttpClient(#[source] reqwest::Error),
64
65    /// Network or transport-level HTTP failure.
66    #[error("HTTP request failed: {0}")]
67    Http(#[from] reqwest::Error),
68
69    /// The server returned a SOAP fault (`<soapenv:Fault>`).
70    #[error("Metadata API SOAP fault (status {status}) [{}]: {}", .fault.code(), .fault.faultstring)]
71    Soap {
72        /// HTTP status code accompanying the fault. SOAP 1.1 faults
73        /// usually arrive with HTTP 500, but Salesforce occasionally
74        /// returns 200 with a fault body, so callers should inspect
75        /// [`fault`](Self::Soap::fault) rather than relying on status
76        /// alone.
77        status: u16,
78        /// The parsed SOAP fault.
79        fault: SoapFault,
80    },
81
82    /// The server returned a non-2xx status with a body that wasn't a
83    /// recognizable SOAP envelope. The raw body is preserved for
84    /// inspection.
85    #[error("HTTP {status} from Metadata API (non-SOAP body): {raw}")]
86    Http4xx5xx {
87        /// HTTP status code returned.
88        status: u16,
89        /// Raw response body.
90        raw: String,
91    },
92
93    /// An auth flow (token acquisition, refresh, OAuth exchange) failed.
94    /// Wraps the underlying [`AuthError`] from `cirrus-auth`.
95    #[error(transparent)]
96    Auth(#[from] AuthError),
97
98    /// XML serialization or deserialization failure.
99    #[error("XML error: {0}")]
100    Xml(String),
101
102    /// Header value rejected by reqwest (invalid bytes, etc.).
103    #[error("invalid header value: {0}")]
104    InvalidHeader(String),
105
106    /// Response could not be interpreted as the requested type or shape.
107    #[error("invalid response: {0}")]
108    InvalidResponse(String),
109
110    /// Client-side argument validation failed before reaching the wire
111    /// (per-call component caps exceeded, required field missing, etc.).
112    /// Distinct from [`Self::InvalidResponse`], which signals a
113    /// server-side shape problem.
114    #[error("invalid argument: {0}")]
115    InvalidArgument(String),
116
117    /// A polling helper hit its configured wall-clock budget before the
118    /// async operation completed. The job is not canceled — re-poll with
119    /// `check_deploy_status` / `check_retrieve_status` to continue
120    /// observing it.
121    #[error("polling timed out: {0}")]
122    PollTimeout(String),
123}
124
125impl From<quick_xml::Error> for MetadataError {
126    fn from(e: quick_xml::Error) -> Self {
127        MetadataError::Xml(e.to_string())
128    }
129}
130
131impl From<quick_xml::DeError> for MetadataError {
132    fn from(e: quick_xml::DeError) -> Self {
133        MetadataError::Xml(e.to_string())
134    }
135}
136
137// `quick_xml::Writer::write_event` returns `std::io::Result<()>` even
138// when the underlying buffer is a `Vec<u8>`. Folding that into the XML
139// variant keeps the public error surface small.
140impl From<std::io::Error> for MetadataError {
141    fn from(e: std::io::Error) -> Self {
142        MetadataError::Xml(e.to_string())
143    }
144}
145
146#[cfg(test)]
147#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn fault_code_strips_namespace_prefix() {
153        let f = SoapFault {
154            faultcode: "sf:INVALID_SESSION_ID".into(),
155            faultstring: "session expired".into(),
156        };
157        assert_eq!(f.code(), "INVALID_SESSION_ID");
158        assert!(f.is_invalid_session());
159    }
160
161    #[test]
162    fn fault_code_passes_through_when_unqualified() {
163        let f = SoapFault {
164            faultcode: "INVALID_TYPE".into(),
165            faultstring: "no such type".into(),
166        };
167        assert_eq!(f.code(), "INVALID_TYPE");
168        assert!(!f.is_invalid_session());
169    }
170
171    #[test]
172    fn soap_error_display_includes_code_and_message() {
173        let err = MetadataError::Soap {
174            status: 500,
175            fault: SoapFault {
176                faultcode: "sf:INVALID_TYPE".into(),
177                faultstring: "no such metadata type".into(),
178            },
179        };
180        let msg = err.to_string();
181        assert!(msg.contains("500"));
182        assert!(msg.contains("INVALID_TYPE"));
183        assert!(msg.contains("no such metadata type"));
184    }
185}