Skip to main content

soma_application/
error.rs

1use serde::Serialize;
2use soma_domain::errors::ServiceErrorKind;
3
4use crate::{PortError, ProviderError};
5
6/// Error type surfaced by the application layer, carrying a stable code,
7/// remediation guidance, and structured source-specific details.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
9pub struct ApplicationError {
10    /// Stable, machine-readable error code.
11    pub code: String,
12    /// Human-readable, redacted description of the failure.
13    pub message: String,
14    /// Whether retrying the operation might succeed.
15    pub retryable: bool,
16    /// Suggested remediation the caller can act on.
17    pub remediation: String,
18    /// Structured details describing where the error originated.
19    pub details: Box<ApplicationErrorDetails>,
20    /// Internal diagnostics never serialized to callers.
21    #[serde(skip)]
22    private_diagnostics: Option<String>,
23}
24
25/// Source-specific detail attached to an [`ApplicationError`].
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(tag = "source", rename_all = "snake_case")]
28pub enum ApplicationErrorDetails {
29    /// No additional structured detail.
30    Generic,
31    /// Error originating from a provider.
32    Provider {
33        /// Version of the provider error schema.
34        schema_version: u32,
35        /// Provider that produced the error.
36        provider: String,
37        /// Action being invoked when the error occurred, if known.
38        action: Option<String>,
39        /// Provider-specific error kind.
40        provider_error_kind: String,
41    },
42    /// Error originating from the service layer.
43    Service {
44        /// Version of the service error schema.
45        schema_version: u8,
46        /// Service-specific error kind.
47        service_error_kind: String,
48        /// Field associated with the error, if any.
49        field: Option<String>,
50        /// Offending value, if any.
51        bad_value: Option<String>,
52        /// Expected value pattern, if any.
53        expected_pattern: Option<String>,
54        /// Machine-readable reason kind, if any.
55        reason_kind: Option<String>,
56        /// Actions available to the caller as recovery options.
57        available_actions: Vec<String>,
58    },
59}
60
61impl ApplicationError {
62    /// Builds a generic `ApplicationError` from its core fields.
63    pub fn new(
64        code: impl Into<String>,
65        message: impl Into<String>,
66        retryable: bool,
67        remediation: impl Into<String>,
68    ) -> Self {
69        Self {
70            code: code.into(),
71            message: message.into(),
72            retryable,
73            remediation: remediation.into(),
74            details: Box::new(ApplicationErrorDetails::Generic),
75            private_diagnostics: None,
76        }
77    }
78
79    /// Attaches structured details and returns the updated error.
80    pub fn with_details(mut self, details: ApplicationErrorDetails) -> Self {
81        self.details = Box::new(details);
82        self
83    }
84
85    /// Returns the internal diagnostics, which are never serialized to callers.
86    pub fn private_diagnostics(&self) -> Option<&str> {
87        self.private_diagnostics.as_deref()
88    }
89
90    pub(crate) fn service(error: &anyhow::Error) -> Self {
91        let classified = crate::classify_service_error(error);
92        Self {
93            code: classified.code,
94            message: classified.message,
95            retryable: classified.retryable,
96            remediation: classified.remediation,
97            details: Box::new(ApplicationErrorDetails::Service {
98                schema_version: classified.schema_version,
99                service_error_kind: classified.kind.as_str().to_owned(),
100                field: classified.field,
101                bad_value: classified.bad_value,
102                expected_pattern: classified.expected_pattern,
103                reason_kind: classified.reason_kind,
104                available_actions: classified
105                    .available_actions
106                    .into_iter()
107                    .map(ToOwned::to_owned)
108                    .collect(),
109            }),
110            private_diagnostics: None,
111        }
112    }
113
114    /// Returns the service error kind when the error came from the service
115    /// layer, otherwise `None`.
116    pub fn service_error_kind(&self) -> Option<&str> {
117        match self.details.as_ref() {
118            ApplicationErrorDetails::Service {
119                service_error_kind, ..
120            } => Some(service_error_kind),
121            ApplicationErrorDetails::Generic | ApplicationErrorDetails::Provider { .. } => None,
122        }
123    }
124
125    /// Returns `true` when this error represents a validation failure.
126    pub fn is_validation(&self) -> bool {
127        self.service_error_kind() == Some(ServiceErrorKind::Validation.as_str())
128    }
129
130    pub(crate) fn legacy(operation: &str, error: impl std::fmt::Display) -> Self {
131        let diagnostic = crate::provider_errors::redact_public(&error.to_string());
132        Self::new(
133            "legacy_operation_failed",
134            format!("{operation} failed: {diagnostic}"),
135            false,
136            "Check Soma service status and retry.",
137        )
138    }
139
140    pub(crate) fn not_found(kind: &str, name: &str) -> Self {
141        Self::new(
142            format!("{kind}_not_found"),
143            format!("unknown {kind} `{name}`"),
144            false,
145            format!("List available {kind}s and retry with a known name."),
146        )
147    }
148}
149
150impl From<ProviderError> for ApplicationError {
151    fn from(error: ProviderError) -> Self {
152        let private_diagnostics = error.private_diagnostics().map(ToOwned::to_owned);
153        Self {
154            code: error.code.to_string(),
155            message: error.message.to_string(),
156            retryable: error.retryable,
157            remediation: error.remediation.to_string(),
158            details: Box::new(ApplicationErrorDetails::Provider {
159                schema_version: error.schema_version,
160                provider: error.provider.to_string(),
161                action: error.action.map(Into::into),
162                provider_error_kind: error.kind.to_owned(),
163            }),
164            private_diagnostics,
165        }
166    }
167}
168
169impl From<PortError> for ApplicationError {
170    fn from(error: PortError) -> Self {
171        let message = crate::provider_errors::redact_public(&error.message);
172        Self::new(error.code, message, error.retryable, error.remediation)
173    }
174}
175
176impl std::fmt::Display for ApplicationError {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        write!(f, "{}: {}", self.code, self.message)
179    }
180}
181
182impl std::error::Error for ApplicationError {}