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, capped at 2 KiB (longer bodies are
90 /// truncated with a marker — non-SOAP shapes come from
91 /// proxies/gateways, and retaining them unboundedly would let
92 /// echoed request data flow into logs).
93 raw: String,
94 },
95
96 /// An auth flow (token acquisition, refresh, OAuth exchange) failed.
97 /// Wraps the underlying [`AuthError`] from `cirrus-auth`.
98 #[error(transparent)]
99 Auth(#[from] AuthError),
100
101 /// XML serialization or deserialization failure.
102 #[error("XML error: {0}")]
103 Xml(String),
104
105 /// Header value rejected by reqwest (invalid bytes, etc.).
106 #[error("invalid header value: {0}")]
107 InvalidHeader(String),
108
109 /// Response could not be interpreted as the requested type or shape.
110 #[error("invalid response: {0}")]
111 InvalidResponse(String),
112
113 /// Client-side argument validation failed before reaching the wire
114 /// (per-call component caps exceeded, required field missing, etc.).
115 /// Distinct from [`Self::InvalidResponse`], which signals a
116 /// server-side shape problem.
117 #[error("invalid argument: {0}")]
118 InvalidArgument(String),
119
120 /// A polling helper hit its configured wall-clock budget before the
121 /// async operation completed. The job is not canceled — re-poll with
122 /// `check_deploy_status` / `check_retrieve_status` to continue
123 /// observing it.
124 #[error("polling timed out: {0}")]
125 PollTimeout(String),
126}
127
128/// Ceiling on how much of a non-SOAP error body is preserved in
129/// [`MetadataError::Http4xx5xx`]. Such bodies come from proxies and
130/// gateways, which can echo request data — capping what we retain
131/// bounds what can end up in the caller's logs via `Display`/`Debug`.
132const RAW_ERROR_BODY_CAP: usize = 2048;
133
134/// Lossily decode an error body, truncating at [`RAW_ERROR_BODY_CAP`]
135/// with a marker.
136pub(crate) fn cap_raw_body(bytes: &[u8]) -> String {
137 let mut body = String::from_utf8_lossy(bytes).into_owned();
138 if body.len() > RAW_ERROR_BODY_CAP {
139 let mut end = RAW_ERROR_BODY_CAP;
140 while !body.is_char_boundary(end) {
141 end -= 1;
142 }
143 body.truncate(end);
144 body.push_str("… <truncated>");
145 }
146 body
147}
148
149impl From<quick_xml::Error> for MetadataError {
150 fn from(e: quick_xml::Error) -> Self {
151 MetadataError::Xml(e.to_string())
152 }
153}
154
155impl From<quick_xml::DeError> for MetadataError {
156 fn from(e: quick_xml::DeError) -> Self {
157 MetadataError::Xml(e.to_string())
158 }
159}
160
161// `quick_xml::Writer::write_event` returns `std::io::Result<()>` even
162// when the underlying buffer is a `Vec<u8>`. Folding that into the XML
163// variant keeps the public error surface small.
164impl From<std::io::Error> for MetadataError {
165 fn from(e: std::io::Error) -> Self {
166 MetadataError::Xml(e.to_string())
167 }
168}
169
170#[cfg(test)]
171#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn cap_raw_body_truncates_oversized_bodies() {
177 let body = "x".repeat(RAW_ERROR_BODY_CAP * 3);
178 let capped = cap_raw_body(body.as_bytes());
179 assert!(capped.ends_with("… <truncated>"));
180 assert!(capped.len() < RAW_ERROR_BODY_CAP + 32);
181 // Small bodies pass through untouched.
182 assert_eq!(cap_raw_body(b"tiny"), "tiny");
183 }
184
185 #[test]
186 fn fault_code_strips_namespace_prefix() {
187 let f = SoapFault {
188 faultcode: "sf:INVALID_SESSION_ID".into(),
189 faultstring: "session expired".into(),
190 };
191 assert_eq!(f.code(), "INVALID_SESSION_ID");
192 assert!(f.is_invalid_session());
193 }
194
195 #[test]
196 fn fault_code_passes_through_when_unqualified() {
197 let f = SoapFault {
198 faultcode: "INVALID_TYPE".into(),
199 faultstring: "no such type".into(),
200 };
201 assert_eq!(f.code(), "INVALID_TYPE");
202 assert!(!f.is_invalid_session());
203 }
204
205 #[test]
206 fn soap_error_display_includes_code_and_message() {
207 let err = MetadataError::Soap {
208 status: 500,
209 fault: SoapFault {
210 faultcode: "sf:INVALID_TYPE".into(),
211 faultstring: "no such metadata type".into(),
212 },
213 };
214 let msg = err.to_string();
215 assert!(msg.contains("500"));
216 assert!(msg.contains("INVALID_TYPE"));
217 assert!(msg.contains("no such metadata type"));
218 }
219}