Skip to main content

soma_domain/
errors.rs

1//! Structured tool/service error DTO shared by REST and MCP error rendering.
2//!
3//! Lives beside `actions.rs` in `soma-domain`: `ToolError`/`ServiceErrorKind`
4//! are produced in `soma-application` (via `classify_service_error`) but the
5//! *shape* is consumed directly by `soma-mcp` (`protocol_errors.rs`) and
6//! `soma-cli` for rendering, independent of whichever layer classified the
7//! error. `soma-domain` is the lowest common ancestor every consumer
8//! (application, api, cli, mcp, integrations, runtime, apps/soma) can depend
9//! on without cycles.
10
11use serde::Serialize;
12use serde_json::{json, Value};
13
14use crate::actions::{action_names, ActionValidationError};
15
16/// High-level category of a service/tool failure, driving HTTP status,
17/// retryability, and the `kind` field rendered to REST and MCP clients.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ServiceErrorKind {
21    /// Input failed validation (bad or missing arguments).
22    Validation,
23    /// The operation exceeded its time budget.
24    Timeout,
25    /// The caller or upstream hit a rate limit.
26    RateLimited,
27    /// Authentication or authorization was rejected.
28    AuthRejected,
29    /// A required upstream dependency was unreachable or unavailable.
30    UpstreamUnavailable,
31    /// The tool ran but failed for an unclassified execution reason.
32    Execution,
33    /// An internal server defect not attributable to the caller.
34    Internal,
35}
36
37impl ServiceErrorKind {
38    /// Returns the stable snake_case string identifier for this kind.
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::Validation => "validation",
42            Self::Timeout => "timeout",
43            Self::RateLimited => "rate_limited",
44            Self::AuthRejected => "auth_rejected",
45            Self::UpstreamUnavailable => "upstream_unavailable",
46            Self::Execution => "execution",
47            Self::Internal => "internal",
48        }
49    }
50
51    /// Maps this kind to the HTTP status code used for REST responses.
52    pub fn http_status_code(self) -> u16 {
53        match self {
54            Self::Validation => 400,
55            Self::AuthRejected => 403,
56            Self::RateLimited => 429,
57            Self::Timeout | Self::UpstreamUnavailable => 503,
58            Self::Execution | Self::Internal => 500,
59        }
60    }
61
62    /// Returns `true` when a client may reasonably retry this class of error.
63    pub fn retryable(self) -> bool {
64        matches!(
65            self,
66            Self::Validation | Self::Timeout | Self::RateLimited | Self::UpstreamUnavailable
67        )
68    }
69}
70
71/// Structured error DTO shared by REST and MCP rendering surfaces.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73pub struct ToolError {
74    /// Version of the error payload schema.
75    pub schema_version: u8,
76    /// High-level failure category.
77    pub kind: ServiceErrorKind,
78    /// Stable machine-readable error code.
79    pub code: String,
80    /// Human-readable error message.
81    pub message: String,
82    /// Whether the caller may retry the operation.
83    pub retryable: bool,
84    /// Actionable guidance for resolving the error.
85    pub remediation: String,
86    /// Offending input field, when the error is field-specific.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub field: Option<String>,
89    /// The rejected value, when a specific value caused the failure.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub bad_value: Option<String>,
92    /// Expected pattern the input should have matched.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub expected_pattern: Option<String>,
95    /// Sub-classification of an execution failure (the underlying kind).
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub reason_kind: Option<String>,
98    /// Valid action names to suggest to the caller.
99    #[serde(skip_serializing_if = "Vec::is_empty")]
100    pub available_actions: Vec<&'static str>,
101}
102
103impl ToolError {
104    /// Builds a `Validation`-kind error from a code, message, and remediation.
105    pub fn validation(
106        code: impl Into<String>,
107        message: impl Into<String>,
108        remediation: impl Into<String>,
109    ) -> Self {
110        Self {
111            schema_version: 1,
112            kind: ServiceErrorKind::Validation,
113            code: code.into(),
114            message: message.into(),
115            retryable: true,
116            remediation: remediation.into(),
117            field: None,
118            bad_value: None,
119            expected_pattern: None,
120            reason_kind: None,
121            available_actions: Vec::new(),
122        }
123    }
124
125    /// Builds a validation error from an [`ActionValidationError`], using the
126    /// full set of known action names as suggestions.
127    pub fn from_action_validation(error: &ActionValidationError) -> Self {
128        Self::from_action_validation_with_actions(error, action_names())
129    }
130
131    /// Builds a validation error from an [`ActionValidationError`] with an
132    /// explicit list of available action names.
133    pub fn from_action_validation_with_actions(
134        error: &ActionValidationError,
135        available_actions: Vec<&'static str>,
136    ) -> Self {
137        let mut tool_error = Self::validation(error.code(), error.to_string(), error.remediation())
138            .with_available_actions(available_actions);
139        if let Some(field) = error.field() {
140            tool_error = tool_error.with_field(field);
141        }
142        if let Some(bad_value) = error.bad_value() {
143            tool_error = tool_error.with_bad_value(bad_value);
144        }
145        tool_error
146    }
147
148    /// Builds an execution error, classifying the underlying `anyhow` error
149    /// into a [`ServiceErrorKind`] and setting retryability accordingly.
150    pub fn execution(error: &anyhow::Error) -> Self {
151        let reason_kind = classify_execution_error(error);
152        Self {
153            schema_version: 1,
154            kind: reason_kind,
155            code: "execution_error".to_owned(),
156            message: "Tool execution failed. Check server logs for details.".to_owned(),
157            retryable: reason_kind.retryable(),
158            remediation: "Check service configuration and upstream availability, then retry. Use action=status or action=help for diagnostics.".to_owned(),
159            field: None,
160            bad_value: None,
161            expected_pattern: None,
162            reason_kind: Some(reason_kind.as_str().to_owned()),
163            available_actions: Vec::new(),
164        }
165    }
166
167    /// Sets the offending input field and returns `self`.
168    pub fn with_field(mut self, field: impl Into<String>) -> Self {
169        self.field = Some(field.into());
170        self
171    }
172
173    /// Sets the rejected value and returns `self`.
174    pub fn with_bad_value(mut self, bad_value: impl Into<String>) -> Self {
175        self.bad_value = Some(bad_value.into());
176        self
177    }
178
179    /// Sets the expected input pattern and returns `self`.
180    pub fn with_expected_pattern(mut self, expected_pattern: impl Into<String>) -> Self {
181        self.expected_pattern = Some(expected_pattern.into());
182        self
183    }
184
185    /// Sets the list of suggested action names and returns `self`.
186    pub fn with_available_actions(mut self, available_actions: Vec<&'static str>) -> Self {
187        self.available_actions = available_actions;
188        self
189    }
190
191    /// Returns the HTTP status code for this error's kind.
192    pub fn http_status_code(&self) -> u16 {
193        self.kind.http_status_code()
194    }
195
196    /// Renders this error as a REST response JSON payload.
197    pub fn to_rest_payload(&self) -> Value {
198        let mut payload = json!({
199            "error": self.message,
200            "kind": self.kind.as_str(),
201            "schema_version": self.schema_version,
202            "code": self.code,
203            "message": self.message,
204            "retryable": self.retryable,
205            "remediation": self.remediation,
206        });
207        self.add_optional_fields(&mut payload);
208        payload
209    }
210
211    /// Renders this error as an MCP tool-error JSON payload, tagged with the
212    /// originating tool and optional action.
213    pub fn to_mcp_payload(&self, tool: &str, action: Option<&str>) -> Value {
214        let mut payload = json!({
215            "kind": "mcp_tool_error",
216            "schema_version": self.schema_version,
217            "code": self.code,
218            "tool": tool,
219            "action": action,
220            "message": self.message,
221            "retryable": self.retryable,
222            "remediation": self.remediation,
223        });
224        payload["service_error_kind"] = json!(self.kind.as_str());
225        self.add_optional_fields(&mut payload);
226        payload
227    }
228
229    fn add_optional_fields(&self, payload: &mut Value) {
230        if let Some(field) = &self.field {
231            payload["field"] = json!(field);
232        }
233        if let Some(bad_value) = &self.bad_value {
234            payload["bad_value"] = json!(bad_value);
235        }
236        if let Some(expected_pattern) = &self.expected_pattern {
237            payload["expected_pattern"] = json!(expected_pattern);
238        }
239        if let Some(reason_kind) = &self.reason_kind {
240            payload["reason_kind"] = json!(reason_kind);
241        }
242        if !self.available_actions.is_empty() {
243            payload["available_actions"] = json!(self.available_actions);
244        }
245    }
246}
247
248/// Alias for [`ToolError`] used where the error is framed as a service error.
249pub type ServiceError = ToolError;
250
251/// Classifies an execution error into a [`ServiceErrorKind`] by scanning its
252/// message for known failure signatures (timeout, rate limit, auth, upstream).
253pub fn classify_execution_error(error: &anyhow::Error) -> ServiceErrorKind {
254    let text = error.to_string().to_ascii_lowercase();
255    if text.contains("timeout") || text.contains("timed out") {
256        ServiceErrorKind::Timeout
257    } else if text.contains("rate limit") || text.contains("429") {
258        ServiceErrorKind::RateLimited
259    } else if text.contains("unauthorized")
260        || text.contains("forbidden")
261        || text.contains("401")
262        || text.contains("403")
263    {
264        ServiceErrorKind::AuthRejected
265    } else if text.contains("connection refused")
266        || text.contains("connection reset")
267        || text.contains("dns")
268        || text.contains("unreachable")
269        || text.contains("temporarily unavailable")
270    {
271        ServiceErrorKind::UpstreamUnavailable
272    } else {
273        ServiceErrorKind::Execution
274    }
275}
276
277#[cfg(test)]
278#[path = "errors_tests.rs"]
279mod tests;