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}