Skip to main content

aion_client/
error.rs

1//! `ClientError` taxonomy and transport/proto error mapping.
2//!
3//! Every variant carries an [`ErrorDetail`]: the server's human detail
4//! message plus, when the wire carried one, the structured `error_type`
5//! discriminator. Nothing the server sends is dropped on the client side —
6//! callers branch on the variant, render `detail.message`, and may surface
7//! `detail.error_type` for diagnostics.
8
9use aion_proto::{ProtoWireError, WireError, WireErrorCode};
10use prost::Message;
11use tonic::Code;
12
13/// Diagnostic payload carried by every [`ClientError`] variant.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ErrorDetail {
16    /// Human-readable detail: the server's wire `message` when the error
17    /// crossed the wire, a precise local description otherwise.
18    pub message: String,
19    /// Concrete typed server error variant (the wire `error_type` field),
20    /// when the server exposed one.
21    pub error_type: Option<String>,
22}
23
24impl ErrorDetail {
25    /// Creates a detail with a message and no typed discriminator.
26    #[must_use]
27    pub fn new(message: impl Into<String>) -> Self {
28        Self {
29            message: message.into(),
30            error_type: None,
31        }
32    }
33
34    /// Creates a detail carrying a typed `error_type` discriminator.
35    #[must_use]
36    pub fn with_type(message: impl Into<String>, error_type: impl Into<String>) -> Self {
37        Self {
38            message: message.into(),
39            error_type: Some(error_type.into()),
40        }
41    }
42}
43
44impl std::fmt::Display for ErrorDetail {
45    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match &self.error_type {
47            Some(error_type) => write!(formatter, "{} [{error_type}]", self.message),
48            None => formatter.write_str(&self.message),
49        }
50    }
51}
52
53impl From<String> for ErrorDetail {
54    fn from(message: String) -> Self {
55        Self::new(message)
56    }
57}
58
59impl From<&str> for ErrorDetail {
60    fn from(message: &str) -> Self {
61        Self::new(message)
62    }
63}
64
65impl From<WireError> for ErrorDetail {
66    fn from(error: WireError) -> Self {
67        Self {
68            message: error.message,
69            error_type: error.error_type,
70        }
71    }
72}
73
74/// Branchable caller-side error taxonomy shared by every aion client SDK.
75///
76/// Display renders `<class>: <detail>` where `<class>` is the stable string
77/// returned by [`ClientError::class`], aligned with the wire error codes
78/// (`not_found`, `namespace_denied`, `invalid_input`, `backend`, ...).
79#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
80pub enum ClientError {
81    /// The requested workflow or run does not exist.
82    #[error("not_found: {detail}")]
83    NotFound {
84        /// Server-supplied detail message.
85        detail: ErrorDetail,
86    },
87    /// A caller-supplied idempotency key conflicts with a different request.
88    #[error("already_exists: {detail}")]
89    AlreadyExists {
90        /// Conflict detail message.
91        detail: ErrorDetail,
92    },
93    /// The workflow query handler ran and reported an application failure.
94    #[error("query_failed: {detail}")]
95    QueryFailed {
96        /// Handler failure detail reported by the workflow.
97        detail: ErrorDetail,
98    },
99    /// The workflow query exceeded its deadline.
100    #[error("query_timeout: {detail}")]
101    QueryTimeout {
102        /// Deadline detail (server window or local deadline).
103        detail: ErrorDetail,
104    },
105    /// The requested workflow query name is not registered.
106    #[error("unknown_query: {detail}")]
107    UnknownQuery {
108        /// Server-supplied detail naming the unknown query.
109        detail: ErrorDetail,
110    },
111    /// The target workflow is terminal or otherwise not running.
112    #[error("not_running: {detail}")]
113    NotRunning {
114        /// Server-supplied detail about the non-running target.
115        detail: ErrorDetail,
116    },
117    /// The call or target workflow was cancelled.
118    #[error("cancelled: {detail}")]
119    Cancelled {
120        /// Cancellation detail message.
121        detail: ErrorDetail,
122    },
123    /// The server or network transport is unavailable.
124    #[error("unavailable: {detail}")]
125    Unavailable {
126        /// Transport/connection failure detail.
127        detail: ErrorDetail,
128    },
129    /// Authentication credentials were rejected.
130    #[error("unauthenticated: {detail}")]
131    Unauthenticated {
132        /// Credential rejection detail.
133        detail: ErrorDetail,
134    },
135    /// The caller's credential was accepted, but the caller has no grant for
136    /// the requested namespace.
137    ///
138    /// This is exactly a namespace-grant failure. Workflow-level invisibility
139    /// — the workflow does not exist, or is owned by another namespace — is
140    /// reported as [`ClientError::NotFound`] so a cross-tenant probe is
141    /// indistinguishable from a nonexistent workflow.
142    ///
143    /// Maps from the AW wire error code `namespace_denied` and gRPC
144    /// `PERMISSION_DENIED`. Distinct from [`ClientError::Unauthenticated`]
145    /// (credential rejected or unvalidatable) and from
146    /// [`ClientError::InvalidArgument`] (malformed or invalid request). Not
147    /// retryable until the caller's grants change.
148    #[error("namespace_denied: {detail}")]
149    NamespaceDenied {
150        /// Server-supplied denial detail message.
151        detail: ErrorDetail,
152    },
153    /// The request was malformed or targets an unsupported operation state.
154    #[error("invalid_input: {detail}")]
155    InvalidArgument {
156        /// Precise description of what was invalid and how to fix it.
157        detail: ErrorDetail,
158    },
159    /// A precondition on the target's current state was not met (e.g. a reopen
160    /// of a run that is not a reopenable terminal). Distinct from
161    /// [`ClientError::NotFound`] (absent) and [`ClientError::InvalidArgument`]
162    /// (malformed request): the target exists but is in the wrong state.
163    ///
164    /// Maps from the wire error code `invalid_state` / gRPC `FailedPrecondition`
165    /// with the typed detail present. Not retryable until the target's state
166    /// changes.
167    #[error("invalid_state: {detail}")]
168    InvalidState {
169        /// Server-supplied precondition-failure detail naming the actual state.
170        detail: ErrorDetail,
171    },
172    /// The server reported an unexpected internal failure.
173    #[error("backend: {detail}")]
174    Server {
175        /// Informational server detail.
176        detail: ErrorDetail,
177    },
178}
179
180macro_rules! detail_constructors {
181    ($(($constructor:ident, $variant:ident, $doc:literal)),+ $(,)?) => {
182        $(
183            #[doc = $doc]
184            #[must_use]
185            pub fn $constructor(detail: impl Into<ErrorDetail>) -> Self {
186                Self::$variant {
187                    detail: detail.into(),
188                }
189            }
190        )+
191    };
192}
193
194impl ClientError {
195    detail_constructors!(
196        (not_found, NotFound, "Creates a not-found error."),
197        (
198            already_exists,
199            AlreadyExists,
200            "Creates an idempotency-conflict error."
201        ),
202        (
203            query_failed,
204            QueryFailed,
205            "Creates a query-handler failure."
206        ),
207        (query_timeout, QueryTimeout, "Creates a query timeout."),
208        (
209            unknown_query,
210            UnknownQuery,
211            "Creates an unknown-query error."
212        ),
213        (not_running, NotRunning, "Creates a not-running error."),
214        (cancelled, Cancelled, "Creates a cancellation error."),
215        (
216            unavailable,
217            Unavailable,
218            "Creates a transport-unavailable error."
219        ),
220        (
221            unauthenticated,
222            Unauthenticated,
223            "Creates a credential-rejection error."
224        ),
225        (
226            namespace_denied,
227            NamespaceDenied,
228            "Creates a namespace-grant denial."
229        ),
230        (
231            invalid_argument,
232            InvalidArgument,
233            "Creates an [`ClientError::InvalidArgument`] carrying a precise message."
234        ),
235        (
236            invalid_state,
237            InvalidState,
238            "Creates an [`ClientError::InvalidState`] precondition failure."
239        ),
240        (
241            server,
242            Server,
243            "Creates an unexpected-server-failure error from a local conversion or server detail."
244        ),
245    );
246
247    /// Stable taxonomy class string, aligned with the wire error codes.
248    #[must_use]
249    pub const fn class(&self) -> &'static str {
250        match self {
251            Self::NotFound { .. } => "not_found",
252            Self::AlreadyExists { .. } => "already_exists",
253            Self::QueryFailed { .. } => "query_failed",
254            Self::QueryTimeout { .. } => "query_timeout",
255            Self::UnknownQuery { .. } => "unknown_query",
256            Self::NotRunning { .. } => "not_running",
257            Self::Cancelled { .. } => "cancelled",
258            Self::Unavailable { .. } => "unavailable",
259            Self::Unauthenticated { .. } => "unauthenticated",
260            Self::NamespaceDenied { .. } => "namespace_denied",
261            Self::InvalidArgument { .. } => "invalid_input",
262            Self::InvalidState { .. } => "invalid_state",
263            Self::Server { .. } => "backend",
264        }
265    }
266
267    /// The diagnostic detail carried by this error.
268    #[must_use]
269    pub const fn detail(&self) -> &ErrorDetail {
270        match self {
271            Self::NotFound { detail }
272            | Self::AlreadyExists { detail }
273            | Self::QueryFailed { detail }
274            | Self::QueryTimeout { detail }
275            | Self::UnknownQuery { detail }
276            | Self::NotRunning { detail }
277            | Self::Cancelled { detail }
278            | Self::Unavailable { detail }
279            | Self::Unauthenticated { detail }
280            | Self::NamespaceDenied { detail }
281            | Self::InvalidArgument { detail }
282            | Self::InvalidState { detail }
283            | Self::Server { detail } => detail,
284        }
285    }
286
287    /// Converts an AW wire error into the client SDK taxonomy, preserving the
288    /// server's message and `error_type` in the carried [`ErrorDetail`].
289    #[must_use]
290    pub fn from_wire_error(error: WireError) -> Self {
291        let code = error.code;
292        let detail = ErrorDetail::from(error);
293        match code {
294            WireErrorCode::NotFound => Self::NotFound { detail },
295            WireErrorCode::NamespaceDenied => Self::NamespaceDenied { detail },
296            WireErrorCode::UnknownQuery => Self::UnknownQuery { detail },
297            WireErrorCode::NotRunning => Self::NotRunning { detail },
298            WireErrorCode::InvalidInput => Self::InvalidArgument { detail },
299            WireErrorCode::InvalidState => Self::InvalidState { detail },
300            // `sequence_conflict` is emitted solely for the server's internal
301            // single-writer invariant violation (a double-writer bug). The
302            // server has no idempotency-key feature, so this is never
303            // AlreadyExists; it is an unexpected server failure.
304            // `deploy_denied` / `version_pinned` belong to the operator
305            // deploy surface, which the caller SDK contract deliberately
306            // excludes (CLIENT-CONTRACT scope); they can never be returned
307            // by a caller SDK operation, so they fall into the generic
308            // server bucket rather than growing the caller taxonomy.
309            WireErrorCode::SequenceConflict
310            | WireErrorCode::Backend
311            | WireErrorCode::DeployDenied
312            | WireErrorCode::VersionPinned => Self::Server { detail },
313            WireErrorCode::QueryFailed => Self::QueryFailed { detail },
314            WireErrorCode::QueryTimeout => Self::QueryTimeout { detail },
315            // `not_owner` (wrong-shard-owner fence) is a retryable routing
316            // signal: the request reached a node that does not own the
317            // workflow's shard. It is transient (re-resolve + retry), so it
318            // joins the `Unavailable` bucket alongside the lagged-stream signal.
319            WireErrorCode::Lagged | WireErrorCode::NotOwner => Self::Unavailable { detail },
320        }
321    }
322
323    /// Converts a proto-encoded wire error into the client SDK taxonomy.
324    #[must_use]
325    pub fn from_proto_wire_error(error: ProtoWireError) -> Self {
326        match WireError::try_from(error) {
327            Ok(error) | Err(error) => Self::from_wire_error(error),
328        }
329    }
330
331    /// Converts a tonic status into the client SDK taxonomy.
332    ///
333    /// The server encodes the full typed `WireError` (code, message,
334    /// `error_type`) into the status details; when present it is
335    /// authoritative. Without decodable details the gRPC code is mapped and
336    /// the status message becomes the detail, so the server's human detail is
337    /// never dropped.
338    #[must_use]
339    pub fn from_status(status: &tonic::Status) -> Self {
340        if let Some(error) = decode_status_details(status) {
341            return Self::from_proto_wire_error(error);
342        }
343
344        let detail = ErrorDetail::new(status.message());
345        match status.code() {
346            Code::NotFound => Self::NotFound { detail },
347            Code::AlreadyExists => Self::AlreadyExists { detail },
348            Code::DeadlineExceeded => Self::QueryTimeout { detail },
349            Code::Cancelled => Self::Cancelled { detail },
350            Code::Unavailable | Code::ResourceExhausted => Self::Unavailable { detail },
351            Code::Unauthenticated => Self::Unauthenticated { detail },
352            Code::PermissionDenied => Self::NamespaceDenied { detail },
353            Code::InvalidArgument => Self::InvalidArgument { detail },
354            // FAILED_PRECONDITION carries both the `not_running` and
355            // `invalid_state` wire codes; the server always attaches the typed
356            // ProtoWireError detail (decoded above), so this bare-code fallback
357            // is only reached without detail. `not_running` is the historical
358            // default kept for that degenerate case.
359            Code::FailedPrecondition => Self::NotRunning { detail },
360            // ABORTED deliberately falls through to Server: the server sends
361            // it only for `sequence_conflict`, an internal single-writer
362            // invariant violation (a double-writer bug), never an
363            // idempotency conflict — so it must not map to AlreadyExists.
364            _ => Self::Server { detail },
365        }
366    }
367
368    /// Converts a tonic transport failure into the client SDK taxonomy,
369    /// preserving the full transport error chain as the detail message.
370    #[must_use]
371    pub fn from_transport_error(error: &tonic::transport::Error) -> Self {
372        Self::Unavailable {
373            detail: ErrorDetail::new(source_chain(error)),
374        }
375    }
376}
377
378/// Joins an error's Display with every `source()` cause, so transport errors
379/// like tonic's bare "transport error" keep their underlying connect/DNS/TLS
380/// detail.
381fn source_chain(error: &(dyn std::error::Error + 'static)) -> String {
382    let mut message = error.to_string();
383    let mut source = error.source();
384    while let Some(cause) = source {
385        message.push_str(": ");
386        message.push_str(&cause.to_string());
387        source = cause.source();
388    }
389    message
390}
391
392fn decode_status_details(status: &tonic::Status) -> Option<ProtoWireError> {
393    let details = status.details();
394    if details.is_empty() {
395        return None;
396    }
397    ProtoWireError::decode(details).ok()
398}
399
400#[cfg(test)]
401mod tests {
402    use super::{ClientError, ErrorDetail};
403
404    fn assert_send_sync_static<T: Send + Sync + 'static>() {}
405
406    #[test]
407    fn client_error_is_send_sync_static() {
408        assert_send_sync_static::<ClientError>();
409    }
410
411    /// Every variant of the taxonomy, exercised so adding a variant breaks
412    /// this list until its class/Display contract is pinned.
413    fn all_variants() -> Vec<ClientError> {
414        vec![
415            ClientError::not_found("d"),
416            ClientError::already_exists("d"),
417            ClientError::query_failed("d"),
418            ClientError::query_timeout("d"),
419            ClientError::unknown_query("d"),
420            ClientError::not_running("d"),
421            ClientError::cancelled("d"),
422            ClientError::unavailable("d"),
423            ClientError::unauthenticated("d"),
424            ClientError::namespace_denied("d"),
425            ClientError::invalid_argument("d"),
426            ClientError::invalid_state("d"),
427            ClientError::server("d"),
428        ]
429    }
430
431    #[test]
432    fn display_is_class_colon_detail_for_every_variant() {
433        let mut classes = Vec::new();
434        for error in all_variants() {
435            assert_eq!(
436                error.to_string(),
437                format!("{}: d", error.class()),
438                "{error:?} Display must be `<class>: <detail>`",
439            );
440            assert_eq!(error.detail().message, "d");
441            classes.push(error.class());
442        }
443        let expected = [
444            "not_found",
445            "already_exists",
446            "query_failed",
447            "query_timeout",
448            "unknown_query",
449            "not_running",
450            "cancelled",
451            "unavailable",
452            "unauthenticated",
453            "namespace_denied",
454            "invalid_input",
455            "invalid_state",
456            "backend",
457        ];
458        assert_eq!(classes, expected, "class strings are a pinned contract");
459    }
460
461    #[test]
462    fn detail_display_appends_the_typed_discriminator() {
463        assert_eq!(ErrorDetail::new("plain").to_string(), "plain");
464        assert_eq!(
465            ErrorDetail::with_type("store unavailable", "Durability").to_string(),
466            "store unavailable [Durability]"
467        );
468        assert_eq!(
469            ClientError::not_found(ErrorDetail::with_type(
470                "workflow was not found",
471                "WorkflowNotFound"
472            ))
473            .to_string(),
474            "not_found: workflow was not found [WorkflowNotFound]"
475        );
476    }
477}