Skip to main content

sc_command/
error.rs

1//! Typed operation error carried in an [`Envelope`](crate::Envelope).
2
3use std::fmt;
4
5use sc_observability_types::{ErrorCode, RecoverableSteps, Remediation};
6use serde::de::{self, DeserializeOwned};
7use serde::ser::SerializeMap;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use serde_json::Value;
10
11use crate::DISCRIMINANT_CODE;
12use crate::exact::{require_exact_keys, required};
13
14/// Detail key that carries a not-recoverable justification on the wire.
15const JUSTIFICATION_KEY: &str = "justification";
16
17const ALL_KINDS: &[ErrorKind] = &[
18    ErrorKind::InvalidInput,
19    ErrorKind::NotFound,
20    ErrorKind::Conflict,
21    ErrorKind::Unavailable,
22    ErrorKind::Internal,
23];
24
25/// Coarse class of an [`OpError`]. The HTTP status is a pure function of this
26/// class.
27///
28/// | Variant | Wire string | HTTP status |
29/// |---|---|---|
30/// | [`InvalidInput`](Self::InvalidInput) | `invalid_input` | 400 |
31/// | [`NotFound`](Self::NotFound) | `not_found` | 404 |
32/// | [`Conflict`](Self::Conflict) | `conflict` | 409 |
33/// | [`Unavailable`](Self::Unavailable) | `unavailable` | 503 |
34/// | [`Internal`](Self::Internal) | `internal` | 500 |
35///
36/// `invalid_input`, `not_found`, and `conflict` are caller errors (4xx).
37/// `unavailable` and `internal` are internal errors (5xx).
38///
39/// The enum is [`non_exhaustive`](https://doc.rust-lang.org/reference/attributes/type_system.html#the-non_exhaustive-attribute)
40/// so a later variant does not break a downstream `match`. The wire spellings
41/// stay the five names above. A JSON string outside that set fails
42/// deserialization. [`http_status_code`](Self::http_status_code) is the only
43/// status table.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[non_exhaustive]
46#[serde(rename_all = "snake_case")]
47pub enum ErrorKind {
48    /// The caller sent a value the operation cannot accept.
49    InvalidInput,
50    /// The named resource does not exist.
51    NotFound,
52    /// The operation conflicts with the current state.
53    Conflict,
54    /// A dependency the operation needs is not available.
55    Unavailable,
56    /// The operation failed for a reason the caller cannot fix.
57    Internal,
58}
59
60impl ErrorKind {
61    /// Every variant this crate defines, in declaration order.
62    #[must_use]
63    pub const fn all() -> &'static [Self] {
64        ALL_KINDS
65    }
66
67    /// HTTP status that belongs to this kind.
68    ///
69    /// This is the only kind-to-status table. The `server` conversion reads
70    /// it through [`StatusCode::from_u16`](https://docs.rs/http/latest/http/status/struct.StatusCode.html#method.from_u16).
71    #[must_use]
72    pub const fn http_status_code(self) -> u16 {
73        match self {
74            Self::InvalidInput => 400,
75            Self::NotFound => 404,
76            Self::Conflict => 409,
77            Self::Unavailable => 503,
78            Self::Internal => 500,
79        }
80    }
81
82    /// Returns whether this kind maps to a 4xx status.
83    #[must_use]
84    pub const fn is_caller_error(self) -> bool {
85        matches!(self, Self::InvalidInput | Self::NotFound | Self::Conflict)
86    }
87
88    const fn wire_name(self) -> &'static str {
89        match self {
90            Self::InvalidInput => "invalid_input",
91            Self::NotFound => "not_found",
92            Self::Conflict => "conflict",
93            Self::Unavailable => "unavailable",
94            Self::Internal => "internal",
95        }
96    }
97}
98
99impl fmt::Display for ErrorKind {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        formatter.write_str(self.wire_name())
102    }
103}
104
105/// Operation error inside a response envelope.
106///
107/// `code` is [`ErrorCode`]. `remediation`, when set, is [`Remediation`].
108/// The wire object always has the keys `kind`, `code`, `message`, `details`,
109/// and `suggested_action`. `details` and `suggested_action` are JSON `null`
110/// when absent. `suggested_action` is a JSON string when a recoverable
111/// remediation has a non-empty first step, never the tagged `Remediation`
112/// object.
113///
114/// A multi-step [`Remediation::Recoverable`] keeps that first step on the
115/// wire. An empty first step writes JSON `null`. [`Remediation::NotRecoverable`]
116/// writes JSON `null` and copies its justification into `details` under
117/// `justification`, so an agent does not execute it as a recovery step.
118/// Those two values do not round-trip as the same Rust variant. A single-step
119/// recoverable remediation with a non-empty step does.
120///
121/// [`Display`] prints `{code}: {message}`. The type also implements
122/// [`std::error::Error`].
123#[derive(Debug, Clone, PartialEq)]
124pub struct OpError {
125    /// Coarse class. Selects the HTTP status.
126    pub kind: ErrorKind,
127    /// Stable machine-readable code.
128    pub code: ErrorCode,
129    /// Human-readable summary.
130    pub message: String,
131    /// Structured context. `None` is JSON `null` on the wire.
132    pub details: Option<Value>,
133    /// Structured remediation. `None` is JSON `null` under `suggested_action`.
134    pub remediation: Option<Remediation>,
135}
136
137impl OpError {
138    /// Builds an error with no details and no remediation.
139    #[must_use]
140    pub fn new(kind: ErrorKind, code: ErrorCode, message: impl Into<String>) -> Self {
141        Self {
142            kind,
143            code,
144            message: message.into(),
145            details: None,
146            remediation: None,
147        }
148    }
149
150    /// Builds an error from a stable code string, with no details and no remediation.
151    ///
152    /// `code` is stored as [`ErrorCode`]. A caller that must not depend on
153    /// `sc-observability-types` uses this constructor instead of [`Self::new`].
154    ///
155    /// # Examples
156    ///
157    /// ```
158    /// use sc_command::{ErrorKind, OpError};
159    ///
160    /// let error = OpError::from_code(
161    ///     ErrorKind::Unavailable,
162    ///     "RUNTIME.REQUEST_TIMEOUT",
163    ///     "timed out",
164    /// );
165    /// assert_eq!(error.code.as_str(), "RUNTIME.REQUEST_TIMEOUT");
166    /// assert_eq!(error.kind, ErrorKind::Unavailable);
167    /// ```
168    #[must_use]
169    pub fn from_code(kind: ErrorKind, code: &'static str, message: impl Into<String>) -> Self {
170        Self::new(kind, ErrorCode::new_static(code), message)
171    }
172
173    /// Attaches structured details.
174    #[must_use]
175    pub fn with_details(mut self, details: Value) -> Self {
176        self.details = Some(details);
177        self
178    }
179
180    /// Attaches one recoverable suggestion.
181    ///
182    /// The text is stored as [`Remediation::recoverable`] and written on the
183    /// wire as a JSON string when it is non-empty.
184    #[must_use]
185    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
186        self.remediation = Some(Remediation::recoverable(
187            suggestion,
188            std::iter::empty::<String>(),
189        ));
190        self
191    }
192
193    /// Attaches remediation from `sc-observability-types`.
194    #[must_use]
195    pub fn with_remediation(mut self, remediation: Remediation) -> Self {
196        self.remediation = Some(remediation);
197        self
198    }
199
200    /// Wire text of `suggested_action` when remediation projects to a
201    /// non-empty recovery step.
202    #[must_use]
203    pub fn suggested_action(&self) -> Option<&str> {
204        self.remediation.as_ref().and_then(wire_suggestion)
205    }
206
207    /// Maps the plain strings a transport failure carries into this error.
208    ///
209    /// `code`, `endpoint`, and `cause` are the strings the transport error
210    /// exposes. `suggested_action` is the caller's already-rendered recovery
211    /// text. This crate does not depend on the transport crate, and it does
212    /// not invent an application name or a daemon verb.
213    ///
214    /// The code `DAEMON.NOT_RUNNING` selects [`ErrorKind::Unavailable`] and
215    /// the message `the daemon is not running`. Any other code selects
216    /// [`ErrorKind::Internal`] and the message
217    /// `transport failure {code} at {endpoint}: {cause}`. `endpoint` and
218    /// `cause` are stored in `details` for every code. `None`, or text that
219    /// is empty after trimming, leaves remediation unset.
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// use sc_command::{ErrorKind, OpError};
225    ///
226    /// let error = OpError::from_transport_strings(
227    ///     "DAEMON.NOT_RUNNING",
228    ///     "uds:app.sock",
229    ///     "connection refused",
230    ///     Some("run my-app daemon start"),
231    /// );
232    /// assert_eq!(error.kind, ErrorKind::Unavailable);
233    /// assert_eq!(error.code.as_str(), "DAEMON.NOT_RUNNING");
234    /// assert_eq!(error.suggested_action(), Some("run my-app daemon start"));
235    /// ```
236    #[must_use]
237    pub fn from_transport_strings(
238        code: &str,
239        endpoint: &str,
240        cause: &str,
241        suggested_action: Option<&str>,
242    ) -> Self {
243        let daemon_down = code == "DAEMON.NOT_RUNNING";
244        let kind = if daemon_down {
245            ErrorKind::Unavailable
246        } else {
247            ErrorKind::Internal
248        };
249        let message = if daemon_down {
250            "the daemon is not running".to_owned()
251        } else {
252            format!("transport failure {code} at {endpoint}: {cause}")
253        };
254        Self {
255            kind,
256            code: ErrorCode::new_owned(code),
257            message,
258            details: Some(serde_json::json!({
259                "endpoint": endpoint,
260                "cause": cause,
261            })),
262            remediation: remediation_from_action(suggested_action),
263        }
264    }
265}
266
267impl fmt::Display for OpError {
268    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
269        write!(formatter, "{}: {}", self.code, self.message)
270    }
271}
272
273impl std::error::Error for OpError {}
274
275impl Serialize for OpError {
276    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
277    where
278        S: Serializer,
279    {
280        let mut map = serializer.serialize_map(Some(5))?;
281        map.serialize_entry("kind", &self.kind)?;
282        map.serialize_entry("code", &self.code)?;
283        map.serialize_entry("message", &self.message)?;
284        map.serialize_entry(
285            "details",
286            &details_json(self.details.as_ref(), self.remediation.as_ref()),
287        )?;
288        map.serialize_entry(
289            "suggested_action",
290            &suggestion_json(self.remediation.as_ref()),
291        )?;
292        map.end()
293    }
294}
295
296impl<'de> Deserialize<'de> for OpError {
297    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
298    where
299        D: Deserializer<'de>,
300    {
301        let value = Value::deserialize(deserializer)?;
302        op_error_from_value(value).map_err(de::Error::custom)
303    }
304}
305
306fn remediation_from_action(suggested_action: Option<&str>) -> Option<Remediation> {
307    let text = suggested_action?.trim();
308    if text.is_empty() {
309        None
310    } else {
311        Some(Remediation::recoverable(text, std::iter::empty::<String>()))
312    }
313}
314
315fn wire_suggestion(remediation: &Remediation) -> Option<&str> {
316    match remediation {
317        Remediation::Recoverable { steps } => {
318            let step = steps.first_step()?;
319            if step.is_empty() { None } else { Some(step) }
320        }
321        Remediation::NotRecoverable { .. } => None,
322    }
323}
324
325fn suggestion_json(remediation: Option<&Remediation>) -> Value {
326    match remediation.and_then(wire_suggestion) {
327        None => Value::Null,
328        Some(text) => Value::String(text.to_owned()),
329    }
330}
331
332fn details_json(details: Option<&Value>, remediation: Option<&Remediation>) -> Value {
333    let Some(Remediation::NotRecoverable { justification }) = remediation else {
334        return details.cloned().unwrap_or(Value::Null);
335    };
336    match details {
337        None => serde_json::json!({ JUSTIFICATION_KEY: justification }),
338        Some(Value::Object(map)) => {
339            let mut map = map.clone();
340            map.insert(
341                JUSTIFICATION_KEY.to_owned(),
342                Value::String(justification.clone()),
343            );
344            Value::Object(map)
345        }
346        Some(other) => serde_json::json!({
347            "value": other,
348            "justification": justification,
349        }),
350    }
351}
352
353fn op_error_from_value(value: Value) -> Result<OpError, String> {
354    let map = match value {
355        Value::Object(map) => map,
356        _ => {
357            return Err(format!("{DISCRIMINANT_CODE}: error must be a JSON object"));
358        }
359    };
360    require_exact_keys(
361        &map,
362        &["kind", "code", "message", "details", "suggested_action"],
363    )?;
364    let kind = from_json_value::<ErrorKind>(required(&map, "kind")?)?;
365    let code = from_json_value::<ErrorCode>(required(&map, "code")?)?;
366    let message = match required(&map, "message")? {
367        Value::String(message) => message.clone(),
368        _ => {
369            return Err(format!(
370                "{DISCRIMINANT_CODE}: message must be a JSON string"
371            ));
372        }
373    };
374    let details = match required(&map, "details")? {
375        Value::Null => None,
376        other => Some(other.clone()),
377    };
378    let remediation = remediation_from_wire(required(&map, "suggested_action")?)?;
379    Ok(OpError {
380        kind,
381        code,
382        message,
383        details,
384        remediation,
385    })
386}
387
388fn remediation_from_wire(value: &Value) -> Result<Option<Remediation>, String> {
389    match value {
390        Value::Null => Ok(None),
391        Value::String(text) if text.is_empty() => Ok(None),
392        Value::String(text) => Ok(Some(Remediation::Recoverable {
393            steps: RecoverableSteps::first(text.clone()),
394        })),
395        _ => Err(format!(
396            "{DISCRIMINANT_CODE}: suggested_action must be a JSON string or null"
397        )),
398    }
399}
400
401fn from_json_value<T>(value: &Value) -> Result<T, String>
402where
403    T: DeserializeOwned,
404{
405    T::deserialize(value).map_err(|error| format!("{DISCRIMINANT_CODE}: {error}"))
406}