Skip to main content

saddle_core/
error.rs

1use std::{error::Error, fmt};
2
3/// Stable error categories used at component and Service boundaries.
4#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5#[non_exhaustive]
6pub enum ErrorKind {
7    InvalidArgument,
8    NotFound,
9    Conflict,
10    Business,
11    Unavailable,
12    Infrastructure,
13    Internal,
14}
15
16/// An error safe to propagate across Saddle component boundaries.
17///
18/// `message` must not contain credentials, SQL parameters, request payloads or
19/// other sensitive implementation details.
20pub struct SaddleError {
21    kind: ErrorKind,
22    code: &'static str,
23    message: String,
24    diagnostic: Option<Box<crate::Diagnostic>>,
25}
26
27impl SaddleError {
28    pub fn new(kind: ErrorKind, code: &'static str, message: impl Into<String>) -> Self {
29        Self {
30            kind,
31            code,
32            message: message.into(),
33            diagnostic: None,
34        }
35    }
36
37    pub const fn kind(&self) -> ErrorKind {
38        self.kind
39    }
40
41    pub const fn code(&self) -> &'static str {
42        self.code
43    }
44
45    pub fn message(&self) -> &str {
46        &self.message
47    }
48
49    pub fn with_diagnostic(mut self, diagnostic: crate::Diagnostic) -> Self {
50        self.diagnostic = Some(Box::new(diagnostic));
51        self
52    }
53
54    pub fn diagnostic(&self) -> Option<&crate::Diagnostic> {
55        self.diagnostic.as_deref()
56    }
57    /// Preserve this cleanup occurrence and link it to the independent primary.
58    pub fn during_cleanup_of(mut self, primary: &Self) -> Self {
59        if let Some(parent) = primary.diagnostic()
60            && let Some(diagnostic) = self.diagnostic.take()
61        {
62            self.diagnostic = Some(Box::new(diagnostic.during_cleanup_of(parent)));
63        }
64        self
65    }
66    /// Propagate existing diagnosis without changing its origin or error semantics.
67    /// An uninstrumented upstream error remains uninstrumented, never fabricated.
68    pub fn wrap_diagnostic(mut self, cause: crate::DiagnosticCause) -> Self {
69        if let Some(diagnostic) = self.diagnostic.take() {
70            self.diagnostic = Some(Box::new(diagnostic.wrap(cause)));
71        }
72        self
73    }
74}
75
76impl fmt::Display for SaddleError {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        if let Some(diagnostic) = &self.diagnostic {
79            // Do not reintroduce an arbitrary upstream message into the safe projection.
80            fmt::Display::fmt(diagnostic, formatter)
81        } else {
82            write!(formatter, "{}: {}", self.code, self.message)
83        }
84    }
85}
86
87impl fmt::Debug for SaddleError {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        if self.diagnostic.is_some() {
90            fmt::Display::fmt(self, formatter)
91        } else {
92            formatter
93                .debug_struct("SaddleError")
94                .field("kind", &self.kind)
95                .field("code", &self.code)
96                .field("message", &self.message)
97                .finish()
98        }
99    }
100}
101
102impl Error for SaddleError {
103    fn source(&self) -> Option<&(dyn Error + 'static)> {
104        self.diagnostic
105            .as_deref()
106            .map(|d| d as &(dyn Error + 'static))
107    }
108}
109
110pub type Result<T> = std::result::Result<T, SaddleError>;
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn cleanup_link_keeps_existing_origin_and_missing_primary() {
118        use crate::{
119            CaptureSite, Diagnostic, DiagnosticCategory, DiagnosticCause, DiagnosticCode,
120            DiagnosticStage,
121        };
122        fn error(code: &'static str) -> SaddleError {
123            SaddleError::new(ErrorKind::Internal, code, "safe").with_diagnostic(
124                Diagnostic::capture(
125                    DiagnosticCategory::UnexpectedError,
126                    CaptureSite::FirstObserved,
127                    DiagnosticCause::new(
128                        DiagnosticStage::FinalizerResource,
129                        DiagnosticCode::new(code).unwrap(),
130                    ),
131                ),
132            )
133        }
134        let primary = error("test.primary");
135        let cleanup = error("test.cleanup");
136        let id = cleanup.diagnostic().unwrap().id();
137        let cleanup = cleanup.during_cleanup_of(&SaddleError::new(
138            ErrorKind::Internal,
139            "test.no_diagnostic",
140            "safe",
141        ));
142        assert_eq!(cleanup.diagnostic().unwrap().id(), id);
143        let before = serde_json::to_value(cleanup.diagnostic().unwrap()).unwrap();
144        let cleanup = cleanup.during_cleanup_of(&primary);
145        let after = serde_json::to_value(cleanup.diagnostic().unwrap()).unwrap();
146        assert_eq!(after["origin"], before["origin"]);
147        assert_eq!(after["causes"], before["causes"]);
148        assert_eq!(
149            after["primary_diagnostic_id"],
150            primary.diagnostic().unwrap().id()
151        );
152        assert_eq!(cleanup.diagnostic().unwrap().id(), id);
153    }
154
155    #[test]
156    fn error_exposes_stable_classification() {
157        let error = SaddleError::new(ErrorKind::NotFound, "user.not_found", "user not found");
158
159        assert_eq!(error.kind(), ErrorKind::NotFound);
160        assert_eq!(error.code(), "user.not_found");
161        assert_eq!(error.to_string(), "user.not_found: user not found");
162    }
163}