Skip to main content

boxology_contract/
error.rs

1//! Typed and erased invocation failures.
2
3use std::error::Error;
4use std::fmt;
5
6use crate::{ContractError, ContractValue, DecodeRole, SlotValue, TypeDescriptor, ValueRef};
7
8/// Producer-owned string diagnostics for an invocation failure.
9///
10/// The code identifies a diagnostic within its producer and is not part of
11/// the S3 wire-envelope code namespace. Keeping detail content string-only
12/// makes it structurally incapable of embedding contract value subtrees.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Detail {
15    code: String,
16    message: Option<String>,
17}
18
19impl Detail {
20    /// Constructs detail with a producer-owned code and no message.
21    pub fn new(code: impl Into<String>) -> Self {
22        Self {
23            code: code.into(),
24            message: None,
25        }
26    }
27
28    /// Adds a producer-owned diagnostic message.
29    pub fn with_message(mut self, message: impl Into<String>) -> Self {
30        self.message = Some(message.into());
31        self
32    }
33
34    /// Returns the producer-owned diagnostic code.
35    pub fn code(&self) -> &str {
36        &self.code
37    }
38
39    /// Returns the diagnostic message, when present.
40    pub fn message(&self) -> Option<&str> {
41        self.message.as_deref()
42    }
43}
44
45impl fmt::Display for Detail {
46    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self.message() {
48            Some(message) => write!(formatter, "{}: {message}", self.code()),
49            None => formatter.write_str(self.code()),
50        }
51    }
52}
53
54/// A typed domain outcome or failure to complete or interpret a call.
55#[non_exhaustive]
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum CallError<E> {
58    /// The capability returned its declared domain error.
59    Domain(E),
60    /// The call deadline expired.
61    Deadline,
62    /// The call was cancelled.
63    Cancelled,
64    /// The target was unavailable.
65    Unavailable(Detail),
66    /// Caller input violated the contract.
67    ContractViolation(Detail),
68    /// The provider produced an invalid response.
69    InvalidResponse(Detail),
70    /// The invocation failed internally.
71    Internal(Detail),
72}
73
74impl<E> fmt::Display for CallError<E> {
75    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            Self::Domain(_) => formatter.write_str("domain error"),
78            Self::Deadline => formatter.write_str("deadline exceeded"),
79            Self::Cancelled => formatter.write_str("call cancelled"),
80            Self::Unavailable(detail) => write!(formatter, "unavailable: {detail}"),
81            Self::ContractViolation(detail) => {
82                write!(formatter, "contract violation: {detail}")
83            }
84            Self::InvalidResponse(detail) => write!(formatter, "invalid response: {detail}"),
85            Self::Internal(detail) => write!(formatter, "internal error: {detail}"),
86        }
87    }
88}
89
90impl<E: fmt::Debug> Error for CallError<E> {}
91
92/// A concrete invocation failure crossing the erased dispatch boundary.
93#[non_exhaustive]
94#[derive(Debug, Clone, PartialEq)]
95pub enum ErasedCallError {
96    /// A decomposed domain-error variant and its variant payload slot.
97    ///
98    /// Known unit variants use [`SlotValue::Null`]. Unknown variants are
99    /// descriptor-guided and may capture any received slot opaquely.
100    Domain {
101        /// The stable domain-error variant tag.
102        error_tag: String,
103        /// The decomposed variant payload.
104        payload: SlotValue,
105    },
106    /// The call deadline expired.
107    Deadline,
108    /// The call was cancelled.
109    Cancelled,
110    /// The target was unavailable.
111    Unavailable(Detail),
112    /// Caller input violated the contract.
113    ContractViolation(Detail),
114    /// The provider produced an invalid response.
115    InvalidResponse(Detail),
116    /// The invocation failed internally.
117    Internal(Detail),
118}
119
120impl ErasedCallError {
121    /// Converts a generated domain error into its erased tag and payload slot.
122    #[doc(hidden)]
123    pub fn from_domain<E: ContractError>(error: &E) -> ErasedCallError {
124        let encoded = match error.encode() {
125            Ok(encoded) => encoded,
126            Err(error) => {
127                return Self::InvalidResponse(conversion_detail("domain_error_encode", error));
128            }
129        };
130        let SlotValue::Value(value) = encoded else {
131            return Self::InvalidResponse(Detail::new("domain_error_shape"));
132        };
133        let ValueRef::Enum { tag, payload } = value.view() else {
134            return Self::InvalidResponse(Detail::new("domain_error_shape"));
135        };
136        Self::Domain {
137            error_tag: tag.into(),
138            payload: payload.clone(),
139        }
140    }
141
142    /// Converts an erased failure back to a generated typed call error.
143    #[doc(hidden)]
144    pub fn into_typed<E: ContractError>(self, error_descriptor: &TypeDescriptor) -> CallError<E> {
145        match self {
146            Self::Domain { error_tag, payload } => {
147                let encoded = SlotValue::Value(ContractValue::enum_value(error_tag, payload));
148                let conformed = match error_descriptor.conform(DecodeRole::ConsumerOutput, encoded)
149                {
150                    Ok(conformed) => conformed,
151                    Err(error) => {
152                        return CallError::InvalidResponse(conversion_detail(
153                            "domain_error_decode",
154                            error,
155                        ));
156                    }
157                };
158                match E::decode(&conformed) {
159                    Ok(error) => CallError::Domain(error),
160                    Err(error) => {
161                        CallError::InvalidResponse(conversion_detail("domain_error_decode", error))
162                    }
163                }
164            }
165            Self::Deadline => CallError::Deadline,
166            Self::Cancelled => CallError::Cancelled,
167            Self::Unavailable(detail) => CallError::Unavailable(detail),
168            Self::ContractViolation(detail) => CallError::ContractViolation(detail),
169            Self::InvalidResponse(detail) => CallError::InvalidResponse(detail),
170            Self::Internal(detail) => CallError::Internal(detail),
171        }
172    }
173}
174
175fn conversion_detail(code: &'static str, error: impl fmt::Display) -> Detail {
176    Detail::new(code).with_message(error.to_string())
177}
178
179impl fmt::Display for ErasedCallError {
180    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181        match self {
182            Self::Domain { error_tag, .. } => write!(formatter, "domain error: {error_tag}"),
183            Self::Deadline => formatter.write_str("deadline exceeded"),
184            Self::Cancelled => formatter.write_str("call cancelled"),
185            Self::Unavailable(detail) => write!(formatter, "unavailable: {detail}"),
186            Self::ContractViolation(detail) => {
187                write!(formatter, "contract violation: {detail}")
188            }
189            Self::InvalidResponse(detail) => write!(formatter, "invalid response: {detail}"),
190            Self::Internal(detail) => write!(formatter, "internal error: {detail}"),
191        }
192    }
193}
194
195impl Error for ErasedCallError {}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::{ContractValue, OpaquePayload, OpaqueTree};
201
202    #[derive(Debug, Clone, PartialEq, Eq)]
203    struct DomainWithoutDisplay;
204
205    fn detail() -> Detail {
206        Detail::new("diagnostic").with_message("context")
207    }
208
209    #[test]
210    fn detail_preserves_strings_and_pins_display() {
211        let code_only = Detail::new("code");
212        assert_eq!(code_only.code(), "code");
213        assert_eq!(code_only.message(), None);
214        assert_eq!(code_only.to_string(), "code");
215        assert_eq!(code_only, code_only.clone());
216
217        let messaged = Detail::new("code").with_message("explanation");
218        assert_eq!(messaged.code(), "code");
219        assert_eq!(messaged.message(), Some("explanation"));
220        assert_eq!(messaged.to_string(), "code: explanation");
221        assert_eq!(Detail::new("").to_string(), "");
222    }
223
224    #[test]
225    fn every_typed_category_is_equal_and_has_stable_display_without_e_display() {
226        let cases = [
227            (CallError::Domain(DomainWithoutDisplay), "domain error"),
228            (CallError::Deadline, "deadline exceeded"),
229            (CallError::Cancelled, "call cancelled"),
230            (
231                CallError::Unavailable(detail()),
232                "unavailable: diagnostic: context",
233            ),
234            (
235                CallError::ContractViolation(detail()),
236                "contract violation: diagnostic: context",
237            ),
238            (
239                CallError::InvalidResponse(detail()),
240                "invalid response: diagnostic: context",
241            ),
242            (
243                CallError::Internal(detail()),
244                "internal error: diagnostic: context",
245            ),
246        ];
247
248        for (error, expected) in cases {
249            assert_eq!(error, error.clone());
250            assert_eq!(error.to_string(), expected);
251        }
252    }
253
254    #[test]
255    fn every_erased_category_is_equal_and_has_stable_display() {
256        let cases = [
257            (
258                ErasedCallError::Domain {
259                    error_tag: "not_found".into(),
260                    payload: SlotValue::Missing,
261                },
262                "domain error: not_found",
263            ),
264            (ErasedCallError::Deadline, "deadline exceeded"),
265            (ErasedCallError::Cancelled, "call cancelled"),
266            (
267                ErasedCallError::Unavailable(detail()),
268                "unavailable: diagnostic: context",
269            ),
270            (
271                ErasedCallError::ContractViolation(detail()),
272                "contract violation: diagnostic: context",
273            ),
274            (
275                ErasedCallError::InvalidResponse(detail()),
276                "invalid response: diagnostic: context",
277            ),
278            (
279                ErasedCallError::Internal(detail()),
280                "internal error: diagnostic: context",
281            ),
282        ];
283
284        for (error, expected) in cases {
285            assert_eq!(error, error.clone());
286            assert_eq!(error.to_string(), expected);
287        }
288    }
289
290    #[test]
291    fn detail_variants_and_redacted_domain_payloads_never_leak() {
292        const SENSITIVE_SENTINEL: &str = "sensitive-never-print";
293        const OPAQUE_SENTINEL: &str = "opaque-never-print";
294
295        let typed_details: [CallError<()>; 4] = [
296            CallError::Unavailable(detail()),
297            CallError::ContractViolation(detail()),
298            CallError::InvalidResponse(detail()),
299            CallError::Internal(detail()),
300        ];
301        let erased_details = [
302            ErasedCallError::Unavailable(detail()),
303            ErasedCallError::ContractViolation(detail()),
304            ErasedCallError::InvalidResponse(detail()),
305            ErasedCallError::Internal(detail()),
306        ];
307        for output in typed_details
308            .iter()
309            .map(|error| format!("{error:?} {error}"))
310            .chain(
311                erased_details
312                    .iter()
313                    .map(|error| format!("{error:?} {error}")),
314            )
315        {
316            assert!(!output.contains(SENSITIVE_SENTINEL));
317            assert!(!output.contains(OPAQUE_SENTINEL));
318        }
319
320        let domains = [
321            (
322                ErasedCallError::Domain {
323                    error_tag: "sensitive".into(),
324                    payload: SlotValue::Value(ContractValue::sensitive(ContractValue::string(
325                        SENSITIVE_SENTINEL,
326                    ))),
327                },
328                "domain error: sensitive",
329            ),
330            (
331                ErasedCallError::Domain {
332                    error_tag: "opaque".into(),
333                    payload: SlotValue::Value(ContractValue::opaque(OpaquePayload::new(
334                        OpaqueTree::String(OPAQUE_SENTINEL.into()),
335                    ))),
336                },
337                "domain error: opaque",
338            ),
339        ];
340        for (error, expected_display) in domains {
341            let debug = format!("{error:?}");
342            let display = error.to_string();
343            assert!(!debug.contains(SENSITIVE_SENTINEL));
344            assert!(!debug.contains(OPAQUE_SENTINEL));
345            assert!(!display.contains(SENSITIVE_SENTINEL));
346            assert!(!display.contains(OPAQUE_SENTINEL));
347            assert!(debug.contains("<redacted>"));
348            assert_eq!(display, expected_display);
349        }
350    }
351
352    #[test]
353    fn public_errors_have_thread_safe_static_bounds() {
354        fn assert_bounds<T: Send + Sync + 'static>() {}
355        fn assert_error<T: Error>() {}
356
357        assert_bounds::<Detail>();
358        assert_bounds::<ErasedCallError>();
359        assert_bounds::<CallError<DomainWithoutDisplay>>();
360        assert_error::<ErasedCallError>();
361        assert_error::<CallError<DomainWithoutDisplay>>();
362    }
363}