Skip to main content

dex_sdk/
handler_error.rs

1// Copyright (c) 2026 Super Durable, Inc.
2//
3// Licensed under the Super Durable Source 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-Super-Durable-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 stack_trace(&self) -> &Backtrace {
192        &self.stack
193    }
194}
195
196impl Display for HandlerError {
197    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
198        formatter.write_str(&self.message)
199    }
200}
201
202impl Error for HandlerError {}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[derive(Debug)]
209    struct SampleDbError;
210
211    impl Display for SampleDbError {
212        fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
213            formatter.write_str("connection refused")
214        }
215    }
216
217    impl Error for SampleDbError {}
218
219    #[test]
220    fn invalid_step_result_has_stable_context_and_type() {
221        let error = HandlerError::invalid_step_result(
222            "OrderFlow",
223            Some("ApproveOrder"),
224            "execute",
225            "missing movement",
226        );
227        assert_eq!("dex_sdk::SdkError::InvalidStepResult", error.error_type());
228        assert!(
229            error
230                .to_string()
231                .contains("Flow OrderFlow Step ApproveOrder")
232        );
233    }
234
235    #[test]
236    fn new_stores_explicit_error_type() {
237        let error = HandlerError::new("PaymentDeclined", "payment failed");
238        assert_eq!("PaymentDeclined", error.error_type());
239        assert_eq!("payment failed", error.to_string());
240    }
241
242    #[test]
243    fn from_error_stores_concrete_type_name() {
244        let error = HandlerError::from_error(SampleDbError);
245        assert_eq!(std::any::type_name::<SampleDbError>(), error.error_type());
246        assert_eq!("connection refused", error.to_string());
247    }
248
249    #[test]
250    fn new_captures_stack_at_construction_site() {
251        fn marker_frame() -> HandlerError {
252            HandlerError::new("TestFailure", "boom")
253        }
254        let rendered = format!("{}", marker_frame().stack_trace());
255        assert!(
256            rendered.contains("marker_frame"),
257            "expected construction-site frame, got {rendered}"
258        );
259    }
260
261    #[test]
262    fn from_error_captures_stack_at_construction_site() {
263        fn marker_frame() -> HandlerError {
264            HandlerError::from_error(SampleDbError)
265        }
266        let rendered = format!("{}", marker_frame().stack_trace());
267        assert!(
268            rendered.contains("marker_frame"),
269            "expected construction-site frame, got {rendered}"
270        );
271    }
272}