Skip to main content

dex_sdk/
handler_error.rs

1// Copyright (c) 2026 Super Durable, Inc.
2//
3// Licensed under the Sustainable Use License 1.0.
4// You may not use this file except in compliance with the License.
5// See the LICENSE file in the repository root.
6//
7// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
9use std::backtrace::Backtrace;
10use std::error::Error;
11use std::fmt::{Display, Formatter};
12
13/// Result returned by application Step and RPC handlers.
14pub type HandlerResult<T> = Result<T, HandlerError>;
15
16/// Reports an application-defined Step or RPC failure to Dex.
17///
18/// Return this error from [`crate::Step::wait_for`], [`crate::Step::execute`], or an RPC handler.
19/// Dex records the message and applies the configured retry or failure policy.
20///
21/// Every constructor captures a stack at the construction site with
22/// [`Backtrace::force_capture`]. The Worker prefers that construction-site
23/// stack over a wrap-site capture when encoding [`dex_protocol::dex::WorkerErrorResponse`].
24///
25/// Prefer [`Self::from_error`] when wrapping another [`Error`] so Dex receives that
26/// concrete type name. Use [`Self::new`] when you want an explicit application error
27/// type string.
28///
29/// # Examples
30///
31/// ```
32/// use std::error::Error;
33/// use std::fmt::{Display, Formatter};
34///
35/// use dex_sdk::HandlerError;
36///
37/// #[derive(Debug)]
38/// struct PaymentError;
39///
40/// impl Display for PaymentError {
41///     fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
42///         formatter.write_str("payment failed")
43///     }
44/// }
45///
46/// impl Error for PaymentError {}
47///
48/// fn charge() -> Result<(), HandlerError> {
49///     Err(HandlerError::new("PaymentDeclined", "payment failed"))
50/// }
51///
52/// fn charge_from_error() -> Result<(), HandlerError> {
53///     Err(HandlerError::from_error(PaymentError))
54/// }
55///
56/// fn charge_with_retry() -> Result<(), HandlerError> {
57///     Err(HandlerError::retry_after(30, "PaymentDeclined", "payment failed"))
58/// }
59/// ```
60#[derive(Debug)]
61pub struct HandlerError {
62    message: String,
63    error_type: String,
64    retry_after_seconds: Option<i32>,
65    stack: Backtrace,
66}
67
68impl HandlerError {
69    /// Creates a handler error with an explicit error type and message.
70    ///
71    /// `error_type` is the stable label Dex stores as
72    /// [`dex_protocol::dex::WorkerErrorResponse::error_type`]. Prefer a short
73    /// application-specific name such as `"PaymentDeclined"` or a library type
74    /// path. `message` is the developer-facing detail string.
75    ///
76    /// Captures a stack at this call site so Dex can report the origin of the
77    /// failure. Prefer constructing the error where the failure is detected
78    /// rather than mapping it later through a distant helper.
79    ///
80    /// # Examples
81    ///
82    /// ```
83    /// use dex_sdk::HandlerError;
84    ///
85    /// let error = HandlerError::new("PaymentDeclined", "payment failed");
86    /// assert_eq!(error.to_string(), "payment failed");
87    /// ```
88    pub fn new(error_type: impl Into<String>, message: impl Into<String>) -> Self {
89        Self {
90            message: message.into(),
91            error_type: error_type.into(),
92            retry_after_seconds: None,
93            stack: Backtrace::force_capture(),
94        }
95    }
96
97    /// Creates a handler error from another [`Error`], preserving its type name.
98    ///
99    /// Uses [`std::any::type_name`] of `E` as
100    /// [`dex_protocol::dex::WorkerErrorResponse::error_type`] and `error.to_string()`
101    /// as the detail. Captures a stack at this call site the same way
102    /// [`Self::new`] does. Prefer this when mapping database, HTTP, or other
103    /// library failures into a Dex handler failure.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use std::error::Error;
109    /// use std::fmt::{Display, Formatter};
110    ///
111    /// use dex_sdk::HandlerError;
112    ///
113    /// #[derive(Debug)]
114    /// struct DbError;
115    ///
116    /// impl Display for DbError {
117    ///     fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
118    ///         formatter.write_str("connection refused")
119    ///     }
120    /// }
121    ///
122    /// impl Error for DbError {}
123    ///
124    /// let error = HandlerError::from_error(DbError);
125    /// assert_eq!(error.to_string(), "connection refused");
126    /// ```
127    pub fn from_error<E: Error>(error: E) -> Self {
128        Self {
129            message: error.to_string(),
130            error_type: std::any::type_name::<E>().to_string(),
131            retry_after_seconds: None,
132            stack: Backtrace::force_capture(),
133        }
134    }
135
136    /// Requests the next retry interval while preserving this failure.
137    ///
138    /// Captures a stack at this call site the same way [`Self::new`] does.
139    /// `after_seconds` is the delay Dex should wait before retrying the Step
140    /// method. `error_type` is stored as
141    /// [`dex_protocol::dex::WorkerErrorResponse::error_type`]. `message` is the
142    /// failure detail reported to Dex.
143    ///
144    /// # Examples
145    ///
146    /// ```
147    /// use dex_sdk::HandlerError;
148    ///
149    /// let error = HandlerError::retry_after(30, "PaymentDeclined", "payment failed");
150    /// assert_eq!(error.to_string(), "payment failed");
151    /// ```
152    pub fn retry_after(
153        after_seconds: i32,
154        error_type: impl Into<String>,
155        message: impl Into<String>,
156    ) -> Self {
157        Self {
158            message: message.into(),
159            error_type: error_type.into(),
160            retry_after_seconds: Some(after_seconds),
161            stack: Backtrace::force_capture(),
162        }
163    }
164
165    pub(crate) fn retry_after_seconds(&self) -> i32 {
166        self.retry_after_seconds.unwrap_or(0)
167    }
168
169    pub(crate) fn invalid_step_result(
170        flow_type: &str,
171        step_type: Option<&str>,
172        method: &str,
173        detail: impl std::fmt::Display,
174    ) -> Self {
175        let target = step_type.map_or_else(
176            || format!("RPC in Flow {flow_type}"),
177            |step_type| format!("Flow {flow_type} Step {step_type}"),
178        );
179        Self {
180            message: format!("{target} {method} returned an invalid result: {detail}"),
181            error_type: "dex_sdk::SdkError::InvalidStepResult".to_string(),
182            retry_after_seconds: None,
183            stack: Backtrace::force_capture(),
184        }
185    }
186
187    pub(crate) fn error_type(&self) -> &str {
188        &self.error_type
189    }
190
191    pub(crate) fn attach_finalizer_error(&mut self, failure: &Self) {
192        self.message
193            .push_str("; buffered Stream finalization failed: ");
194        self.message.push_str(&failure.message);
195    }
196
197    pub(crate) fn stack_trace(&self) -> &Backtrace {
198        &self.stack
199    }
200}
201
202impl Display for HandlerError {
203    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
204        formatter.write_str(&self.message)
205    }
206}
207
208impl Error for HandlerError {}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[derive(Debug)]
215    struct SampleDbError;
216
217    impl Display for SampleDbError {
218        fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
219            formatter.write_str("connection refused")
220        }
221    }
222
223    impl Error for SampleDbError {}
224
225    #[test]
226    fn invalid_step_result_has_stable_context_and_type() {
227        let error = HandlerError::invalid_step_result(
228            "OrderFlow",
229            Some("ApproveOrder"),
230            "execute",
231            "missing movement",
232        );
233        assert_eq!("dex_sdk::SdkError::InvalidStepResult", error.error_type());
234        assert!(
235            error
236                .to_string()
237                .contains("Flow OrderFlow Step ApproveOrder")
238        );
239    }
240
241    #[test]
242    fn new_stores_explicit_error_type() {
243        let error = HandlerError::new("PaymentDeclined", "payment failed");
244        assert_eq!("PaymentDeclined", error.error_type());
245        assert_eq!("payment failed", error.to_string());
246    }
247
248    #[test]
249    fn from_error_stores_concrete_type_name() {
250        let error = HandlerError::from_error(SampleDbError);
251        assert_eq!(std::any::type_name::<SampleDbError>(), error.error_type());
252        assert_eq!("connection refused", error.to_string());
253    }
254
255    #[test]
256    fn new_captures_stack_at_construction_site() {
257        fn marker_frame() -> HandlerError {
258            HandlerError::new("TestFailure", "boom")
259        }
260        let rendered = format!("{}", marker_frame().stack_trace());
261        assert!(
262            rendered.contains("marker_frame"),
263            "expected construction-site frame, got {rendered}"
264        );
265    }
266
267    #[test]
268    fn from_error_captures_stack_at_construction_site() {
269        fn marker_frame() -> HandlerError {
270            HandlerError::from_error(SampleDbError)
271        }
272        let rendered = format!("{}", marker_frame().stack_trace());
273        assert!(
274            rendered.contains("marker_frame"),
275            "expected construction-site frame, got {rendered}"
276        );
277    }
278}