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