Skip to main content

scm_sdk/
error.rs

1//! Structured errors with safe ordinary formatting.
2
3use std::{backtrace::Backtrace, fmt, sync::Arc, time::Duration};
4use zeroize::Zeroizing;
5
6mod problem;
7pub(crate) use problem::body_retry_after;
8pub use problem::{ErrorMetadata, ErrorType, FailureKind, ProblemDetails, ProblemFieldError};
9
10/// SDK result type.
11pub type Result<T> = std::result::Result<T, Error>;
12
13/// The failure category, independent of arbitrary upstream messages.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum ErrorKind {
17    Configuration,
18    Validation,
19    Destination,
20    Authentication,
21    Io,
22    Transport,
23    Http,
24    Decode,
25    Limit,
26    Deadline,
27    Cancelled,
28    Protocol,
29    Pagination,
30}
31
32/// Whether the remote operation may have committed before the local failure.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum RemoteOutcome {
36    NotSent,
37    Rejected,
38    Unknown,
39}
40
41/// No raw body, credential, URL, transport source error, or upstream message is retained.
42/// Allowlisted problem fields and explicit application messages have separate getters.
43#[derive(Clone)]
44pub struct Error {
45    kind: ErrorKind,
46    status: Option<u16>,
47    operation: Option<&'static str>,
48    request_id: Option<String>,
49    service_code: Option<String>,
50    retry_after: Option<Duration>,
51    remote_outcome: RemoteOutcome,
52    details: Option<Arc<ErrorDetails>>,
53}
54#[derive(Clone, Default)]
55struct ErrorDetails {
56    custom_type: Option<Option<ErrorType>>,
57    message: Option<Zeroizing<String>>,
58    problem: Option<ProblemDetails>,
59    failure_kind: Option<FailureKind>,
60    backtrace: Option<Arc<Backtrace>>,
61}
62
63impl Error {
64    /// Construct an error with no remote request sent.
65    pub fn new(kind: ErrorKind) -> Self {
66        Self {
67            kind,
68            status: None,
69            operation: None,
70            request_id: None,
71            service_code: None,
72            retry_after: None,
73            remote_outcome: RemoteOutcome::NotSent,
74            details: None,
75        }
76    }
77    /// Construct an application error with checked metadata and an explicit message.
78    /// The message is bounded to 8 KiB and omitted from all ordinary formatting.
79    pub fn custom(
80        message: impl AsRef<str>,
81        error_type: Option<ErrorType>,
82        metadata: ErrorMetadata,
83    ) -> Result<Self> {
84        let message = message.as_ref();
85        if message.len() > 8192 {
86            return Err(Self::new(ErrorKind::Limit));
87        }
88        let message = Zeroizing::new(message.to_owned());
89        let mut error = Self::new(ErrorKind::Protocol);
90        error.status = metadata.status;
91        error.retry_after = metadata.retry_after;
92        error.details = Some(Arc::new(ErrorDetails {
93            message: Some(message),
94            custom_type: Some(error_type),
95            problem: metadata.problem,
96            failure_kind: metadata.failure_kind,
97            backtrace: None,
98        }));
99        Ok(error)
100    }
101    pub fn name(&self) -> &'static str {
102        "ScmSdkError"
103    }
104    pub fn message(&self) -> Option<&str> {
105        self.details
106            .as_ref()
107            .and_then(|details| details.message.as_ref())
108            .map(|value| value.as_str())
109    }
110    pub fn error_type(&self) -> Option<ErrorType> {
111        if let Some(details) = &self.details
112            && let Some(value) = details.custom_type
113        {
114            return value;
115        }
116        Some(match self.kind {
117            ErrorKind::Configuration => ErrorType::MissingVariable,
118            ErrorKind::Limit
119                if self
120                    .status
121                    .is_some_and(|value| value == 101 || (200..300).contains(&value)) =>
122            {
123                ErrorType::ResponseValidation
124            }
125            ErrorKind::Limit | ErrorKind::Destination
126                if self.remote_outcome != RemoteOutcome::NotSent || self.status.is_some() =>
127            {
128                ErrorType::ClientSideError
129            }
130            ErrorKind::Protocol
131                if self.remote_outcome != RemoteOutcome::NotSent || self.status.is_some() =>
132            {
133                ErrorType::ClientSideError
134            }
135            ErrorKind::Validation | ErrorKind::Destination | ErrorKind::Limit => {
136                ErrorType::UserRequestPayloadError
137            }
138            ErrorKind::Authentication => ErrorType::OAuthError,
139            ErrorKind::Http if self.status.is_some_and(|value| value >= 500) => {
140                ErrorType::ServerSideError
141            }
142            ErrorKind::Http | ErrorKind::Transport | ErrorKind::Io => ErrorType::ClientSideError,
143            ErrorKind::Decode => ErrorType::ResponseValidation,
144            _ => ErrorType::SdkError,
145        })
146    }
147    pub fn problem(&self) -> Option<&ProblemDetails> {
148        self.details
149            .as_ref()
150            .and_then(|details| details.problem.as_ref())
151    }
152    pub fn failure_kind(&self) -> Option<FailureKind> {
153        self.details
154            .as_ref()
155            .and_then(|details| details.failure_kind)
156    }
157    pub fn metadata(&self) -> ErrorMetadata {
158        ErrorMetadata {
159            problem: self.problem().cloned(),
160            failure_kind: self.failure_kind(),
161            status: self.status,
162            retry_after: self.retry_after,
163        }
164    }
165    /// Opt-in native stack capture. It is accessible only through the explicit getter.
166    pub fn capture_backtrace(mut self) -> Self {
167        self.details_mut().backtrace = Some(Arc::new(Backtrace::force_capture()));
168        self
169    }
170    pub fn backtrace(&self) -> Option<&Backtrace> {
171        self.details
172            .as_ref()
173            .and_then(|details| details.backtrace.as_deref())
174    }
175    fn details_mut(&mut self) -> &mut ErrorDetails {
176        Arc::make_mut(
177            self.details
178                .get_or_insert_with(|| Arc::new(ErrorDetails::default())),
179        )
180    }
181    pub fn kind(&self) -> ErrorKind {
182        self.kind
183    }
184    pub fn status(&self) -> Option<u16> {
185        self.status
186    }
187    pub fn operation(&self) -> Option<&'static str> {
188        self.operation
189    }
190    /// Explicit access to an allowlisted correlation field; ordinary formatting redacts it.
191    pub fn request_id(&self) -> Option<&str> {
192        self.request_id.as_deref()
193    }
194    /// Explicit access to the service code; ordinary formatting redacts it.
195    pub fn service_code(&self) -> Option<&str> {
196        self.service_code.as_deref()
197    }
198    pub fn retry_after(&self) -> Option<Duration> {
199        self.retry_after
200    }
201    pub fn remote_outcome(&self) -> RemoteOutcome {
202        self.remote_outcome
203    }
204    pub(crate) fn with_operation(mut self, name: &'static str) -> Self {
205        self.operation = Some(name);
206        self
207    }
208    pub(crate) fn with_outcome(mut self, value: RemoteOutcome) -> Self {
209        self.remote_outcome = value;
210        self
211    }
212    pub(crate) fn with_status(mut self, status: u16) -> Self {
213        self.status = Some(status);
214        self
215    }
216    pub(crate) fn with_failure_kind(mut self, value: FailureKind) -> Self {
217        self.details_mut().failure_kind = Some(value);
218        self
219    }
220    pub(crate) fn with_http_body(mut self, body: &[u8]) -> Self {
221        self.details_mut().problem = ProblemDetails::parse(body).ok().flatten();
222        self.details_mut().failure_kind = Some(FailureKind::Http);
223        // Body guidance is diagnostic metadata only; transport replay remains separately authorized.
224        if self.retry_after.is_none() {
225            self.retry_after = body_retry_after(body);
226        }
227        self
228    }
229    pub(crate) fn with_response_metadata(
230        mut self,
231        status: u16,
232        request_id: Option<String>,
233    ) -> Self {
234        self = self.with_status(status);
235        if self.request_id.is_none() {
236            self.request_id = request_id;
237        }
238        self
239    }
240    pub(crate) fn with_metadata(
241        mut self,
242        request_id: Option<String>,
243        code: Option<String>,
244        delay: Option<Duration>,
245    ) -> Self {
246        self.request_id = request_id;
247        self.service_code = code;
248        self.retry_after = delay;
249        self
250    }
251}
252
253impl fmt::Debug for Error {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        f.debug_struct("Error")
256            .field("kind", &self.kind)
257            .field("status", &self.status)
258            .field("operation", &self.operation)
259            .field("remote_outcome", &self.remote_outcome)
260            .field("retry_after", &self.retry_after)
261            .field("has_request_id", &self.request_id.is_some())
262            .field("has_service_code", &self.service_code.is_some())
263            .field("failure_kind", &self.failure_kind())
264            .field("has_problem", &self.problem().is_some())
265            .finish()
266    }
267}
268impl fmt::Display for Error {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        write!(f, "SCM SDK {:?}", self.kind)?;
271        if let Some(status) = self.status {
272            write!(f, " (HTTP {status})")?;
273        }
274        Ok(())
275    }
276}
277impl std::error::Error for Error {}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    #[test]
283    fn explicit_application_messages_and_backtraces_are_never_formatted() {
284        assert!(std::mem::size_of::<Error>() <= 128);
285        let metadata = ErrorMetadata::new()
286            .with_status(429)
287            .unwrap()
288            .with_failure_kind(FailureKind::Http)
289            .with_retry_after(Duration::from_millis(2));
290        let error = Error::custom(
291            "synthetic caller secret",
292            Some(ErrorType::ClientSideError),
293            metadata,
294        )
295        .unwrap()
296        .capture_backtrace();
297        assert_eq!(error.message(), Some("synthetic caller secret"));
298        assert_eq!(
299            error.error_type().unwrap().as_str(),
300            "AISEC_CLIENT_SIDE_ERROR"
301        );
302        assert_eq!(error.failure_kind(), Some(FailureKind::Http));
303        assert_eq!(error.metadata().status(), Some(429));
304        assert!(error.backtrace().is_some());
305        assert!(!format!("{error:?} {error}").contains("secret"));
306        assert_eq!(error.name(), "ScmSdkError");
307        assert!(Error::custom("x".repeat(8193), None, ErrorMetadata::default()).is_err());
308        assert!(ErrorMetadata::new().with_status(0).is_err());
309        assert_eq!(
310            Error::new(ErrorKind::Http).with_status(503).error_type(),
311            Some(ErrorType::ServerSideError)
312        );
313        assert!(
314            Error::custom("message", None, ErrorMetadata::default())
315                .unwrap()
316                .error_type()
317                .is_none()
318        );
319    }
320}