1use std::{error::Error, fmt};
4
5pub type SoapResult<T> = Result<T, SoapError>;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum SoapErrorKind {
14 NotFound,
16 Validation,
18 Conflict,
20 Unauthorized,
22 Forbidden,
24 RateLimited,
26 Domain,
28 Unsupported,
30 Timeout,
32 Unavailable,
34 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 pub const fn is_reportable(self) -> bool {
56 matches!(
57 self,
58 Self::Timeout | Self::Unavailable | Self::Infrastructure
59 )
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub enum ErrorTransience {
70 Permanent,
72 Transient,
74 Unknown,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Hash)]
83pub struct DiagnosticId(String);
84
85impl DiagnosticId {
86 pub fn new(value: impl Into<String>) -> Self {
88 Self(value.into())
89 }
90
91 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#[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 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 pub fn not_found(message: impl Into<String>) -> Self {
143 Self::new(SoapErrorKind::NotFound, message)
144 }
145
146 pub fn validation(message: impl Into<String>) -> Self {
148 Self::new(SoapErrorKind::Validation, message)
149 }
150
151 pub fn conflict(message: impl Into<String>) -> Self {
153 Self::new(SoapErrorKind::Conflict, message)
154 }
155
156 pub fn unauthorized() -> Self {
158 Self::new(SoapErrorKind::Unauthorized, "unauthorized")
159 }
160
161 pub fn forbidden() -> Self {
163 Self::new(SoapErrorKind::Forbidden, "forbidden")
164 }
165
166 pub fn rate_limited() -> Self {
168 Self::new(SoapErrorKind::RateLimited, "rate limit exceeded")
169 }
170
171 pub fn domain(message: impl Into<String>) -> Self {
173 Self::new(SoapErrorKind::Domain, message)
174 }
175
176 pub fn unsupported(message: impl Into<String>) -> Self {
178 Self::new(SoapErrorKind::Unsupported, message)
179 }
180
181 pub fn timeout(message: impl Into<String>) -> Self {
183 Self::new(SoapErrorKind::Timeout, message)
184 }
185
186 pub fn unavailable(message: impl Into<String>) -> Self {
188 Self::new(SoapErrorKind::Unavailable, message)
189 }
190
191 pub fn infrastructure(message: impl Into<String>) -> Self {
193 Self::new(SoapErrorKind::Infrastructure, message)
194 }
195
196 #[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 #[must_use]
208 pub const fn with_transience(mut self, transience: ErrorTransience) -> Self {
209 self.transience = transience;
210 self
211 }
212
213 #[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 pub const fn kind(&self) -> SoapErrorKind {
222 self.kind
223 }
224
225 pub fn message(&self) -> &str {
227 &self.message
228 }
229
230 pub const fn transience(&self) -> ErrorTransience {
232 self.transience
233 }
234
235 pub fn diagnostic_id(&self) -> Option<&DiagnosticId> {
237 self.diagnostic_id.as_ref()
238 }
239
240 pub const fn is_transient(&self) -> bool {
242 matches!(self.transience, ErrorTransience::Transient)
243 }
244
245 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}