1use std::error::Error;
4use std::fmt;
5
6use crate::{ContractError, ContractValue, DecodeRole, SlotValue, TypeDescriptor, ValueRef};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Detail {
15 code: String,
16 message: Option<String>,
17}
18
19impl Detail {
20 pub fn new(code: impl Into<String>) -> Self {
22 Self {
23 code: code.into(),
24 message: None,
25 }
26 }
27
28 pub fn with_message(mut self, message: impl Into<String>) -> Self {
30 self.message = Some(message.into());
31 self
32 }
33
34 pub fn code(&self) -> &str {
36 &self.code
37 }
38
39 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#[non_exhaustive]
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum CallError<E> {
58 Domain(E),
60 Deadline,
62 Cancelled,
64 Unavailable(Detail),
66 ContractViolation(Detail),
68 InvalidResponse(Detail),
70 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#[non_exhaustive]
94#[derive(Debug, Clone, PartialEq)]
95pub enum ErasedCallError {
96 Domain {
101 error_tag: String,
103 payload: SlotValue,
105 },
106 Deadline,
108 Cancelled,
110 Unavailable(Detail),
112 ContractViolation(Detail),
114 InvalidResponse(Detail),
116 Internal(Detail),
118}
119
120impl ErasedCallError {
121 #[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 #[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}