Skip to main content

dex_sdk/
sdk_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::error::Error;
10use std::fmt::{Display, Formatter};
11
12use dex_protocol::dex::{ErrorSubStatus as ProtoErrorSubStatus, ServiceErrorResponse};
13use prost::Message;
14use prost_types::Any;
15use tonic::Status;
16
17use crate::GrpcCode;
18
19/// Result returned by fallible Dex SDK operations.
20pub type SdkResult<T> = Result<T, SdkError>;
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23/// Provides Dex-specific detail beyond a gRPC status code.
24pub enum ErrorSubStatus {
25    /// Dex returned no specific status.
26    Uncategorized,
27    /// A start request targeted an already-existing Flow ID.
28    FlowAlreadyStarted,
29    /// The requested Flow does not exist or is no longer active.
30    FlowNotExists,
31    /// A Worker Step or RPC invocation failed.
32    WorkerApiError,
33    /// A long-poll deadline elapsed while the Flow remained active.
34    LongPollTimeout,
35    /// A pending Channel message ID no longer exists.
36    ChannelMessageNotFound,
37}
38
39#[derive(Debug)]
40/// Preserves one failed Dex service operation and its transport source.
41pub struct ServiceError {
42    code: GrpcCode,
43    sub_status: ErrorSubStatus,
44    detail: String,
45    operation: &'static str,
46    flow_id: Option<String>,
47    source: Status,
48}
49
50impl ServiceError {
51    /// Returns the outer gRPC status code.
52    pub fn code(&self) -> GrpcCode {
53        self.code
54    }
55
56    /// Returns the decoded Dex-specific status.
57    pub fn sub_status(&self) -> ErrorSubStatus {
58        self.sub_status
59    }
60
61    /// Returns the most specific server or transport detail available.
62    pub fn detail(&self) -> &str {
63        &self.detail
64    }
65
66    /// Returns the SDK operation name that failed.
67    pub fn operation(&self) -> &str {
68        self.operation
69    }
70
71    /// Returns the targeted Flow ID when the operation had one.
72    pub fn flow_id(&self) -> Option<&str> {
73        self.flow_id.as_deref()
74    }
75
76    pub(crate) fn local(operation: &'static str, detail: impl Into<String>) -> Self {
77        let detail = detail.into();
78        Self {
79            code: GrpcCode::Unknown,
80            sub_status: ErrorSubStatus::Uncategorized,
81            detail: detail.clone(),
82            operation,
83            flow_id: None,
84            source: Status::unknown(detail),
85        }
86    }
87}
88
89impl Display for ServiceError {
90    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
91        formatter.write_str(&self.detail)
92    }
93}
94
95impl Error for ServiceError {
96    fn source(&self) -> Option<&(dyn Error + 'static)> {
97        Some(&self.source)
98    }
99}
100
101#[derive(Clone, Debug, Eq, PartialEq)]
102/// Preserves the original Worker-side error carried by a service failure.
103pub struct WorkerError {
104    code: Option<GrpcCode>,
105    error_type: String,
106    detail: String,
107}
108
109impl WorkerError {
110    /// Returns the Worker gRPC status when available.
111    pub fn code(&self) -> Option<GrpcCode> {
112        self.code
113    }
114
115    /// Returns the Worker exception or error type name.
116    pub fn error_type(&self) -> &str {
117        &self.error_type
118    }
119
120    /// Returns the Worker-provided failure detail.
121    pub fn detail(&self) -> &str {
122        &self.detail
123    }
124}
125
126#[derive(Debug)]
127/// Reports service, definition, value, handler, and Flow-completion failures.
128///
129/// Match specific variants when recovery differs. [`Self::service_error`] extracts common gRPC
130/// metadata from every service-backed variant.
131pub enum SdkError {
132    /// A general Dex service or transport call failed.
133    Service {
134        /// Structured service failure.
135        service: ServiceError,
136    },
137    /// Starting a Flow conflicted with an existing Flow ID.
138    FlowAlreadyStarted {
139        /// Structured start-operation failure.
140        service: ServiceError,
141    },
142    /// An operation requiring an existing Flow targeted an unknown ID.
143    FlowNotFound {
144        /// Structured lookup failure.
145        service: ServiceError,
146    },
147    /// An operation requiring an active Flow targeted a terminal or unknown ID.
148    FlowNotActive {
149        /// Structured active-Flow failure.
150        service: ServiceError,
151    },
152    /// A Worker Step or RPC handler failed.
153    WorkerInvocation {
154        /// Outer Dex service failure.
155        service: ServiceError,
156        /// Original Worker-side failure metadata.
157        worker: Box<WorkerError>,
158    },
159    /// A locking RPC could not acquire all requested Attribute locks.
160    RpcLockConflict {
161        /// Structured aborted-operation failure.
162        service: ServiceError,
163    },
164    /// A long-poll timeout elapsed without indicating a Flow failure.
165    LongPollTimeout {
166        /// Structured timeout failure.
167        service: ServiceError,
168    },
169    /// A requested Channel message is no longer pending.
170    ChannelMessageNotFound {
171        /// Structured deletion failure.
172        service: ServiceError,
173    },
174    /// A registered Flow, Step, RPC, or persistence definition is invalid.
175    FlowDefinition {
176        /// Developer-actionable contract violation.
177        message: String,
178    },
179    /// An SDK method received an invalid argument.
180    InvalidArgument {
181        /// Developer-actionable argument violation.
182        message: String,
183    },
184    /// An application value could not be serialized or decoded.
185    ValueMapping {
186        /// Developer-actionable mapping failure.
187        message: String,
188    },
189    /// A Step returned a value that violates its handler contract.
190    InvalidStepResult {
191        /// Flow type containing the Step.
192        flow_type: String,
193        /// Step type that returned the invalid value.
194        step_type: String,
195        /// Developer-actionable result violation.
196        detail: String,
197    },
198}
199
200impl Display for SdkError {
201    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
202        match self {
203            Self::Service { service }
204            | Self::FlowAlreadyStarted { service }
205            | Self::FlowNotFound { service }
206            | Self::FlowNotActive { service }
207            | Self::RpcLockConflict { service }
208            | Self::LongPollTimeout { service }
209            | Self::ChannelMessageNotFound { service } => Display::fmt(service, formatter),
210            Self::WorkerInvocation { service, .. } => Display::fmt(service, formatter),
211            Self::InvalidStepResult { detail, .. } => formatter.write_str(detail),
212            Self::FlowDefinition { message }
213            | Self::InvalidArgument { message }
214            | Self::ValueMapping { message } => formatter.write_str(message),
215        }
216    }
217}
218
219impl Error for SdkError {
220    fn source(&self) -> Option<&(dyn Error + 'static)> {
221        self.service_error()
222            .map(|error| error as &(dyn Error + 'static))
223    }
224}
225
226impl SdkError {
227    /// Returns shared service metadata for service-backed variants.
228    ///
229    /// Local definition, argument, mapping, and invalid-result errors return `None`.
230    pub fn service_error(&self) -> Option<&ServiceError> {
231        match self {
232            Self::Service { service }
233            | Self::FlowAlreadyStarted { service }
234            | Self::FlowNotFound { service }
235            | Self::FlowNotActive { service }
236            | Self::RpcLockConflict { service }
237            | Self::LongPollTimeout { service }
238            | Self::ChannelMessageNotFound { service } => Some(service),
239            Self::WorkerInvocation { service, .. } => Some(service),
240            _ => None,
241        }
242    }
243
244    pub(crate) fn from_status(
245        status: Status,
246        operation: &'static str,
247        flow_id: Option<&str>,
248        requirement: FlowTargetRequirement,
249    ) -> Self {
250        let code = status.code();
251        let decoded = decode_details(status.details());
252        let details = decoded.as_ref().ok().and_then(|details| details.as_ref());
253        let detail = details
254            .filter(|details| !details.detail.is_empty())
255            .map_or_else(
256                || {
257                    if status.message().is_empty() {
258                        format!("{code:?}")
259                    } else {
260                        status.message().to_string()
261                    }
262                },
263                |details| details.detail.clone(),
264            );
265        let sub_status = details
266            .map(|details| map_sub_status(details.sub_status))
267            .unwrap_or(ErrorSubStatus::Uncategorized);
268        let service = ServiceError {
269            code,
270            sub_status,
271            detail,
272            operation,
273            flow_id: flow_id.map(str::to_string),
274            source: status,
275        };
276        if decoded.is_err() {
277            return Self::Service { service };
278        }
279        match sub_status {
280            ErrorSubStatus::FlowAlreadyStarted => Self::FlowAlreadyStarted { service },
281            ErrorSubStatus::FlowNotExists => match requirement {
282                FlowTargetRequirement::Active => Self::FlowNotActive { service },
283                FlowTargetRequirement::Existing => Self::FlowNotFound { service },
284                FlowTargetRequirement::None => Self::Service { service },
285            },
286            ErrorSubStatus::WorkerApiError if code == GrpcCode::Aborted => {
287                Self::RpcLockConflict { service }
288            }
289            ErrorSubStatus::WorkerApiError => {
290                let details = details.expect("worker details were decoded");
291                Self::WorkerInvocation {
292                    service,
293                    worker: Box::new(WorkerError {
294                        code: (details.original_worker_error_status != 0)
295                            .then(|| GrpcCode::from_i32(details.original_worker_error_status)),
296                        error_type: details.original_worker_error_type.clone(),
297                        detail: details.original_worker_error_detail.clone(),
298                    }),
299                }
300            }
301            ErrorSubStatus::LongPollTimeout => Self::LongPollTimeout { service },
302            ErrorSubStatus::ChannelMessageNotFound => Self::ChannelMessageNotFound { service },
303            ErrorSubStatus::Uncategorized => Self::Service { service },
304        }
305    }
306}
307
308#[derive(Clone, Copy)]
309pub(crate) enum FlowTargetRequirement {
310    None,
311    Existing,
312    Active,
313}
314
315#[derive(Clone, PartialEq, Message)]
316struct GoogleRpcStatus {
317    #[prost(int32, tag = "1")]
318    code: i32,
319    #[prost(string, tag = "2")]
320    message: String,
321    #[prost(message, repeated, tag = "3")]
322    details: Vec<Any>,
323}
324
325fn decode_details(bytes: &[u8]) -> Result<Option<ServiceErrorResponse>, prost::DecodeError> {
326    if bytes.is_empty() {
327        return Ok(None);
328    }
329    match GoogleRpcStatus::decode(bytes) {
330        Ok(status) => {
331            for detail in status.details {
332                if detail.type_url.ends_with("/dex.ServiceErrorResponse") {
333                    return ServiceErrorResponse::decode(detail.value.as_slice()).map(Some);
334                }
335            }
336            Ok(None)
337        }
338        Err(status_error) => ServiceErrorResponse::decode(bytes)
339            .map(Some)
340            .or(Err(status_error)),
341    }
342}
343
344fn map_sub_status(value: i32) -> ErrorSubStatus {
345    match ProtoErrorSubStatus::try_from(value).ok() {
346        Some(ProtoErrorSubStatus::FlowAlreadyStarted) => ErrorSubStatus::FlowAlreadyStarted,
347        Some(ProtoErrorSubStatus::FlowNotExists) => ErrorSubStatus::FlowNotExists,
348        Some(ProtoErrorSubStatus::WorkerApiError) => ErrorSubStatus::WorkerApiError,
349        Some(ProtoErrorSubStatus::LongPollTimeOut) => ErrorSubStatus::LongPollTimeout,
350        Some(ProtoErrorSubStatus::ChannelMessageNotFound) => ErrorSubStatus::ChannelMessageNotFound,
351        _ => ErrorSubStatus::Uncategorized,
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn missing_flow_uses_endpoint_lifecycle_requirement() {
361        let found = SdkError::from_status(
362            rich_status(GrpcCode::NotFound, ProtoErrorSubStatus::FlowNotExists),
363            "describe_flow",
364            Some("flow-id"),
365            FlowTargetRequirement::Existing,
366        );
367        assert!(matches!(found, SdkError::FlowNotFound { .. }));
368
369        let active = SdkError::from_status(
370            rich_status(GrpcCode::NotFound, ProtoErrorSubStatus::FlowNotExists),
371            "publish",
372            Some("flow-id"),
373            FlowTargetRequirement::Active,
374        );
375        assert!(matches!(active, SdkError::FlowNotActive { .. }));
376    }
377
378    #[test]
379    fn worker_details_and_lock_conflicts_are_distinct() {
380        let invocation = SdkError::from_status(
381            worker_status(GrpcCode::FailedPrecondition),
382            "invoke_rpc",
383            Some("flow-id"),
384            FlowTargetRequirement::Active,
385        );
386        match invocation {
387            SdkError::WorkerInvocation { service, worker } => {
388                assert_eq!(GrpcCode::FailedPrecondition, service.code());
389                assert_eq!(Some(GrpcCode::InvalidArgument), worker.code());
390                assert_eq!("ApplicationError", worker.error_type());
391                assert_eq!("invalid order", worker.detail());
392            }
393            error => panic!("expected WorkerInvocation, got {error:?}"),
394        }
395
396        let conflict = SdkError::from_status(
397            worker_status(GrpcCode::Aborted),
398            "invoke_rpc",
399            Some("flow-id"),
400            FlowTargetRequirement::Active,
401        );
402        assert!(matches!(conflict, SdkError::RpcLockConflict { .. }));
403    }
404
405    #[test]
406    fn missing_and_malformed_details_use_generic_fallback() {
407        let missing = SdkError::from_status(
408            Status::internal("missing"),
409            "search_flows",
410            None,
411            FlowTargetRequirement::None,
412        );
413        assert!(matches!(missing, SdkError::Service { .. }));
414
415        let malformed = SdkError::from_status(
416            Status::with_details(GrpcCode::Internal, "malformed", vec![255].into()),
417            "search_flows",
418            None,
419            FlowTargetRequirement::None,
420        );
421        assert!(matches!(malformed, SdkError::Service { .. }));
422    }
423
424    fn rich_status(code: GrpcCode, sub_status: ProtoErrorSubStatus) -> Status {
425        status_with_response(
426            code,
427            ServiceErrorResponse {
428                detail: "service detail".to_string(),
429                sub_status: sub_status as i32,
430                ..ServiceErrorResponse::default()
431            },
432        )
433    }
434
435    fn worker_status(code: GrpcCode) -> Status {
436        status_with_response(
437            code,
438            ServiceErrorResponse {
439                detail: "worker failure".to_string(),
440                sub_status: ProtoErrorSubStatus::WorkerApiError as i32,
441                original_worker_error_detail: "invalid order".to_string(),
442                original_worker_error_type: "ApplicationError".to_string(),
443                original_worker_error_status: GrpcCode::InvalidArgument as i32,
444                original_worker_error_stack_trace: String::new(),
445            },
446        )
447    }
448
449    fn status_with_response(code: GrpcCode, response: ServiceErrorResponse) -> Status {
450        let status = GoogleRpcStatus {
451            code: code as i32,
452            message: "gRPC detail".to_string(),
453            details: vec![Any {
454                type_url: "type.googleapis.com/dex.ServiceErrorResponse".to_string(),
455                value: response.encode_to_vec(),
456            }],
457        };
458        Status::with_details(code, "gRPC detail", status.encode_to_vec().into())
459    }
460}