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}