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, 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}