Skip to main content

soaprs_core/
error.rs

1//! Error model shared by the core ports.
2
3use std::{error::Error, fmt};
4
5/// Result type returned by soaprs ports.
6pub type SoapResult<T> = Result<T, SoapError>;
7
8/// Stable category of a [`SoapError`].
9///
10/// Applications and transports should branch on this value instead of parsing
11/// an error message or depending on an infrastructure-specific source type.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum SoapErrorKind {
14    /// A requested resource does not exist.
15    NotFound,
16    /// Input or domain validation failed.
17    Validation,
18    /// The requested change conflicts with existing state.
19    Conflict,
20    /// Authentication is missing or invalid.
21    Unauthorized,
22    /// The authenticated actor is not allowed to perform an operation.
23    Forbidden,
24    /// A domain invariant or operation failed.
25    Domain,
26    /// A requested capability is not supported by an adapter.
27    Unsupported,
28    /// An infrastructure operation exceeded its deadline.
29    Timeout,
30    /// An infrastructure dependency is temporarily unavailable.
31    Unavailable,
32    /// An infrastructure component failed for another reason.
33    Infrastructure,
34}
35
36impl SoapErrorKind {
37    const fn default_transience(self) -> ErrorTransience {
38        match self {
39            Self::Timeout | Self::Unavailable => ErrorTransience::Transient,
40            Self::Infrastructure => ErrorTransience::Unknown,
41            Self::NotFound
42            | Self::Validation
43            | Self::Conflict
44            | Self::Unauthorized
45            | Self::Forbidden
46            | Self::Domain
47            | Self::Unsupported => ErrorTransience::Permanent,
48        }
49    }
50
51    /// Indicates whether this category should normally be reported to
52    /// monitoring.
53    pub const fn is_reportable(self) -> bool {
54        matches!(
55            self,
56            Self::Timeout | Self::Unavailable | Self::Infrastructure
57        )
58    }
59}
60
61/// Whether the underlying failure condition is expected to be short-lived.
62///
63/// A transient error does not by itself make retrying an operation safe. The
64/// caller must also account for idempotency and whether the operation may have
65/// completed before the error was observed.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum ErrorTransience {
68    /// Repeating the same operation is not expected to remove the failure.
69    Permanent,
70    /// The failure condition may disappear without changing the request.
71    Transient,
72    /// The adapter cannot reliably classify the failure condition.
73    Unknown,
74}
75
76/// Opaque identifier used to correlate a returned error with diagnostics.
77///
78/// Generation belongs to an application boundary or observability adapter, so
79/// this type does not require UUID or tracing dependencies in `soaprs-core`.
80#[derive(Debug, Clone, PartialEq, Eq, Hash)]
81pub struct DiagnosticId(String);
82
83impl DiagnosticId {
84    /// Wraps an identifier generated by an application boundary.
85    pub fn new(value: impl Into<String>) -> Self {
86        Self(value.into())
87    }
88
89    /// Returns the identifier as text.
90    pub fn as_str(&self) -> &str {
91        &self.0
92    }
93}
94
95impl fmt::Display for DiagnosticId {
96    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97        formatter.write_str(&self.0)
98    }
99}
100
101impl From<String> for DiagnosticId {
102    fn from(value: String) -> Self {
103        Self::new(value)
104    }
105}
106
107impl From<&str> for DiagnosticId {
108    fn from(value: &str) -> Self {
109        Self::new(value)
110    }
111}
112
113/// Stable application error with optional technical diagnostics.
114///
115/// `message` is safe application-facing context. Infrastructure details stay
116/// in the standard [`Error::source`] chain and must not be exposed directly in
117/// transport responses.
118#[derive(Debug)]
119pub struct SoapError {
120    kind: SoapErrorKind,
121    message: String,
122    transience: ErrorTransience,
123    diagnostic_id: Option<DiagnosticId>,
124    source: Option<Box<dyn Error + Send + Sync + 'static>>,
125}
126
127impl SoapError {
128    /// Creates an error with the category's default transience.
129    pub fn new(kind: SoapErrorKind, message: impl Into<String>) -> Self {
130        Self {
131            kind,
132            message: message.into(),
133            transience: kind.default_transience(),
134            diagnostic_id: None,
135            source: None,
136        }
137    }
138
139    /// Creates a missing-resource error.
140    pub fn not_found(message: impl Into<String>) -> Self {
141        Self::new(SoapErrorKind::NotFound, message)
142    }
143
144    /// Creates an input or domain validation error.
145    pub fn validation(message: impl Into<String>) -> Self {
146        Self::new(SoapErrorKind::Validation, message)
147    }
148
149    /// Creates an error for an operation that conflicts with current state.
150    pub fn conflict(message: impl Into<String>) -> Self {
151        Self::new(SoapErrorKind::Conflict, message)
152    }
153
154    /// Creates an authentication error.
155    pub fn unauthorized() -> Self {
156        Self::new(SoapErrorKind::Unauthorized, "unauthorized")
157    }
158
159    /// Creates an authorization error.
160    pub fn forbidden() -> Self {
161        Self::new(SoapErrorKind::Forbidden, "forbidden")
162    }
163
164    /// Creates a domain-operation error.
165    pub fn domain(message: impl Into<String>) -> Self {
166        Self::new(SoapErrorKind::Domain, message)
167    }
168
169    /// Creates an unsupported-capability error.
170    pub fn unsupported(message: impl Into<String>) -> Self {
171        Self::new(SoapErrorKind::Unsupported, message)
172    }
173
174    /// Creates a transient timeout error.
175    pub fn timeout(message: impl Into<String>) -> Self {
176        Self::new(SoapErrorKind::Timeout, message)
177    }
178
179    /// Creates a transient dependency-unavailable error.
180    pub fn unavailable(message: impl Into<String>) -> Self {
181        Self::new(SoapErrorKind::Unavailable, message)
182    }
183
184    /// Creates an unclassified infrastructure error.
185    pub fn infrastructure(message: impl Into<String>) -> Self {
186        Self::new(SoapErrorKind::Infrastructure, message)
187    }
188
189    /// Attaches the original technical cause to this error.
190    #[must_use]
191    pub fn with_source<E>(mut self, source: E) -> Self
192    where
193        E: Error + Send + Sync + 'static,
194    {
195        self.source = Some(Box::new(source));
196        self
197    }
198
199    /// Overrides the adapter's classification of the failure condition.
200    #[must_use]
201    pub const fn with_transience(mut self, transience: ErrorTransience) -> Self {
202        self.transience = transience;
203        self
204    }
205
206    /// Attaches an identifier generated by an application boundary.
207    #[must_use]
208    pub fn with_diagnostic_id(mut self, diagnostic_id: impl Into<DiagnosticId>) -> Self {
209        self.diagnostic_id = Some(diagnostic_id.into());
210        self
211    }
212
213    /// Returns the stable error category.
214    pub const fn kind(&self) -> SoapErrorKind {
215        self.kind
216    }
217
218    /// Returns safe application-facing context.
219    pub fn message(&self) -> &str {
220        &self.message
221    }
222
223    /// Returns the failure-condition classification.
224    pub const fn transience(&self) -> ErrorTransience {
225        self.transience
226    }
227
228    /// Returns the optional diagnostics correlation identifier.
229    pub fn diagnostic_id(&self) -> Option<&DiagnosticId> {
230        self.diagnostic_id.as_ref()
231    }
232
233    /// Indicates whether the underlying failure condition may be short-lived.
234    pub const fn is_transient(&self) -> bool {
235        matches!(self.transience, ErrorTransience::Transient)
236    }
237
238    /// Indicates whether the error should normally be reported to monitoring.
239    pub const fn is_reportable(&self) -> bool {
240        self.kind.is_reportable()
241    }
242}
243
244impl fmt::Display for SoapError {
245    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
246        match self.kind {
247            SoapErrorKind::NotFound => write!(formatter, "not found: {}", self.message),
248            SoapErrorKind::Validation => {
249                write!(formatter, "validation failed: {}", self.message)
250            }
251            SoapErrorKind::Conflict => write!(formatter, "conflict: {}", self.message),
252            SoapErrorKind::Unauthorized | SoapErrorKind::Forbidden | SoapErrorKind::Domain => {
253                formatter.write_str(&self.message)
254            }
255            SoapErrorKind::Unsupported => write!(formatter, "unsupported: {}", self.message),
256            SoapErrorKind::Timeout => write!(formatter, "timeout: {}", self.message),
257            SoapErrorKind::Unavailable => write!(formatter, "unavailable: {}", self.message),
258            SoapErrorKind::Infrastructure => {
259                write!(formatter, "infrastructure error: {}", self.message)
260            }
261        }
262    }
263}
264
265impl Error for SoapError {
266    fn source(&self) -> Option<&(dyn Error + 'static)> {
267        self.source
268            .as_deref()
269            .map(|source| source as &(dyn Error + 'static))
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use std::{error::Error, io};
276
277    use super::{DiagnosticId, ErrorTransience, SoapError, SoapErrorKind};
278
279    #[test]
280    fn stable_kinds_have_expected_reporting_and_transience_defaults() {
281        let validation = SoapError::validation("name is empty");
282        assert_eq!(validation.kind(), SoapErrorKind::Validation);
283        assert_eq!(validation.transience(), ErrorTransience::Permanent);
284        assert!(!validation.is_reportable());
285        assert!(!validation.is_transient());
286
287        let timeout = SoapError::timeout("database query");
288        assert_eq!(timeout.kind(), SoapErrorKind::Timeout);
289        assert_eq!(timeout.transience(), ErrorTransience::Transient);
290        assert!(timeout.is_reportable());
291        assert!(timeout.is_transient());
292
293        let infrastructure = SoapError::infrastructure("database operation failed");
294        assert_eq!(infrastructure.transience(), ErrorTransience::Unknown);
295        assert!(infrastructure.is_reportable());
296    }
297
298    #[test]
299    fn original_source_is_preserved_but_not_exposed_by_display() {
300        let error = SoapError::unavailable("user database is unavailable").with_source(
301            io::Error::new(io::ErrorKind::ConnectionRefused, "secret driver detail"),
302        );
303
304        let Some(source) = error.source() else {
305            panic!("source must be preserved");
306        };
307        assert_eq!(source.to_string(), "secret driver detail");
308        assert_eq!(
309            error.to_string(),
310            "unavailable: user database is unavailable"
311        );
312        assert!(!error.to_string().contains("secret driver detail"));
313    }
314
315    #[test]
316    fn mapped_business_error_can_keep_technical_source() {
317        let error = SoapError::conflict("email already exists").with_source(io::Error::new(
318            io::ErrorKind::AlreadyExists,
319            "unique constraint users_email_key",
320        ));
321
322        assert_eq!(error.kind(), SoapErrorKind::Conflict);
323        assert!(!error.is_reportable());
324        assert_eq!(
325            error.source().map(ToString::to_string),
326            Some("unique constraint users_email_key".into())
327        );
328    }
329
330    #[test]
331    fn diagnostic_identifier_is_opaque_and_optional() {
332        let error = SoapError::infrastructure("storage failed")
333            .with_diagnostic_id(DiagnosticId::new("0195d6b4-test"));
334
335        assert_eq!(
336            error.diagnostic_id().map(DiagnosticId::as_str),
337            Some("0195d6b4-test")
338        );
339    }
340
341    #[test]
342    fn adapter_can_override_transience_without_changing_kind() {
343        let error = SoapError::infrastructure("serialization failure")
344            .with_transience(ErrorTransience::Transient);
345
346        assert_eq!(error.kind(), SoapErrorKind::Infrastructure);
347        assert!(error.is_transient());
348    }
349}