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 request reached a server that is not the current owner of the
173 /// target's shard, so it could not be served there.
174 ///
175 /// A ROUTING signal, not a connectivity one: the endpoint answered. It is
176 /// transient — cluster ownership moves on failover and the caller may retry
177 /// or re-resolve — which is why it is worth retrying, but it must not be
178 /// confused with [`ClientError::Unavailable`], whose remedy ("check your
179 /// endpoint") is wrong here and sends the operator hunting a network fault
180 /// that does not exist.
181 ///
182 /// "Retryable" here means the CALLER may retry, not that this SDK does it
183 /// for them: the only automatic retry in this crate is the event stream's
184 /// resume, and `stream.rs`'s `is_retryable` matches `Unavailable` alone.
185 ///
186 /// Maps from the wire error code `not_owner` / gRPC `ABORTED` with the typed
187 /// detail present.
188 #[error("not_owner: {detail}")]
189 NotOwner {
190 /// Server-supplied detail naming the shard and what owns it.
191 detail: ErrorDetail,
192 },
193 /// The server reported an unexpected internal failure.
194 #[error("backend: {detail}")]
195 Server {
196 /// Informational server detail.
197 detail: ErrorDetail,
198 },
199}
200
201macro_rules! detail_constructors {
202 ($(($constructor:ident, $variant:ident, $doc:literal)),+ $(,)?) => {
203 $(
204 #[doc = $doc]
205 #[must_use]
206 pub fn $constructor(detail: impl Into<ErrorDetail>) -> Self {
207 Self::$variant {
208 detail: detail.into(),
209 }
210 }
211 )+
212 };
213}
214
215impl ClientError {
216 detail_constructors!(
217 (not_found, NotFound, "Creates a not-found error."),
218 (
219 already_exists,
220 AlreadyExists,
221 "Creates an idempotency-conflict error."
222 ),
223 (
224 query_failed,
225 QueryFailed,
226 "Creates a query-handler failure."
227 ),
228 (query_timeout, QueryTimeout, "Creates a query timeout."),
229 (
230 unknown_query,
231 UnknownQuery,
232 "Creates an unknown-query error."
233 ),
234 (not_running, NotRunning, "Creates a not-running error."),
235 (cancelled, Cancelled, "Creates a cancellation error."),
236 (
237 unavailable,
238 Unavailable,
239 "Creates a transport-unavailable error."
240 ),
241 (
242 unauthenticated,
243 Unauthenticated,
244 "Creates a credential-rejection error."
245 ),
246 (
247 namespace_denied,
248 NamespaceDenied,
249 "Creates a namespace-grant denial."
250 ),
251 (
252 invalid_argument,
253 InvalidArgument,
254 "Creates an [`ClientError::InvalidArgument`] carrying a precise message."
255 ),
256 (
257 invalid_state,
258 InvalidState,
259 "Creates an [`ClientError::InvalidState`] precondition failure."
260 ),
261 (
262 not_owner,
263 NotOwner,
264 "Creates a wrong-shard-owner routing refusal."
265 ),
266 (
267 server,
268 Server,
269 "Creates an unexpected-server-failure error from a local conversion or server detail."
270 ),
271 );
272
273 /// Stable taxonomy class string, aligned with the wire error codes.
274 #[must_use]
275 pub const fn class(&self) -> &'static str {
276 match self {
277 Self::NotFound { .. } => "not_found",
278 Self::AlreadyExists { .. } => "already_exists",
279 Self::QueryFailed { .. } => "query_failed",
280 Self::QueryTimeout { .. } => "query_timeout",
281 Self::UnknownQuery { .. } => "unknown_query",
282 Self::NotRunning { .. } => "not_running",
283 Self::Cancelled { .. } => "cancelled",
284 Self::Unavailable { .. } => "unavailable",
285 Self::Unauthenticated { .. } => "unauthenticated",
286 Self::NamespaceDenied { .. } => "namespace_denied",
287 Self::InvalidArgument { .. } => "invalid_input",
288 Self::InvalidState { .. } => "invalid_state",
289 Self::NotOwner { .. } => "not_owner",
290 Self::Server { .. } => "backend",
291 }
292 }
293
294 /// The diagnostic detail carried by this error.
295 #[must_use]
296 pub const fn detail(&self) -> &ErrorDetail {
297 match self {
298 Self::NotFound { detail }
299 | Self::AlreadyExists { detail }
300 | Self::QueryFailed { detail }
301 | Self::QueryTimeout { detail }
302 | Self::UnknownQuery { detail }
303 | Self::NotRunning { detail }
304 | Self::Cancelled { detail }
305 | Self::Unavailable { detail }
306 | Self::Unauthenticated { detail }
307 | Self::NamespaceDenied { detail }
308 | Self::InvalidArgument { detail }
309 | Self::InvalidState { detail }
310 | Self::NotOwner { detail }
311 | Self::Server { detail } => detail,
312 }
313 }
314
315 /// Converts an AW wire error into the client SDK taxonomy, preserving the
316 /// server's message and `error_type` in the carried [`ErrorDetail`].
317 #[must_use]
318 pub fn from_wire_error(error: WireError) -> Self {
319 let code = error.code;
320 let detail = ErrorDetail::from(error);
321 match code {
322 WireErrorCode::NotFound => Self::NotFound { detail },
323 WireErrorCode::NamespaceDenied => Self::NamespaceDenied { detail },
324 WireErrorCode::UnknownQuery => Self::UnknownQuery { detail },
325 WireErrorCode::NotRunning => Self::NotRunning { detail },
326 WireErrorCode::InvalidInput => Self::InvalidArgument { detail },
327 WireErrorCode::InvalidState => Self::InvalidState { detail },
328 // `sequence_conflict` is emitted solely for the server's internal
329 // single-writer invariant violation (a double-writer bug). The
330 // server has no idempotency-key feature, so this is never
331 // AlreadyExists; it is an unexpected server failure.
332 // `deploy_denied` / `version_pinned` belong to the operator
333 // deploy surface, which the caller SDK contract deliberately
334 // excludes (CLIENT-CONTRACT scope); they can never be returned
335 // by a caller SDK operation, so they fall into the generic
336 // server bucket rather than growing the caller taxonomy.
337 WireErrorCode::SequenceConflict
338 | WireErrorCode::Backend
339 | WireErrorCode::DeployDenied
340 | WireErrorCode::VersionPinned => Self::Server { detail },
341 WireErrorCode::QueryFailed => Self::QueryFailed { detail },
342 WireErrorCode::QueryTimeout => Self::QueryTimeout { detail },
343 WireErrorCode::Lagged => Self::Unavailable { detail },
344 // `not_owner` (wrong-shard-owner fence) is a retryable ROUTING
345 // signal: the request reached a node that does not own the
346 // target's shard. It used to collapse into `Unavailable`, which
347 // made every CLI surface tell the operator to "check --endpoint" —
348 // a wrong hint that cost the failover investigation real diagnosis
349 // time. It is its own class now, typed at the taxonomy so no
350 // surface has to read message text to tell the two apart.
351 WireErrorCode::NotOwner => Self::NotOwner { detail },
352 }
353 }
354
355 /// Converts a proto-encoded wire error into the client SDK taxonomy.
356 #[must_use]
357 pub fn from_proto_wire_error(error: ProtoWireError) -> Self {
358 match WireError::try_from(error) {
359 Ok(error) | Err(error) => Self::from_wire_error(error),
360 }
361 }
362
363 /// Converts a tonic status into the client SDK taxonomy.
364 ///
365 /// The server encodes the full typed `WireError` (code, message,
366 /// `error_type`) into the status details; when present it is
367 /// authoritative. Without decodable details the gRPC code is mapped and
368 /// the status message becomes the detail, so the server's human detail is
369 /// never dropped.
370 #[must_use]
371 pub fn from_status(status: &tonic::Status) -> Self {
372 if let Some(error) = decode_status_details(status) {
373 return Self::from_proto_wire_error(error);
374 }
375
376 let detail = ErrorDetail::new(status.message());
377 match status.code() {
378 Code::NotFound => Self::NotFound { detail },
379 Code::AlreadyExists => Self::AlreadyExists { detail },
380 Code::DeadlineExceeded => Self::QueryTimeout { detail },
381 Code::Cancelled => Self::Cancelled { detail },
382 Code::Unavailable | Code::ResourceExhausted => Self::Unavailable { detail },
383 Code::Unauthenticated => Self::Unauthenticated { detail },
384 Code::PermissionDenied => Self::NamespaceDenied { detail },
385 Code::InvalidArgument => Self::InvalidArgument { detail },
386 // FAILED_PRECONDITION carries both the `not_running` and
387 // `invalid_state` wire codes; the server always attaches the typed
388 // ProtoWireError detail (decoded above), so this bare-code fallback
389 // is only reached without detail. `not_running` is the historical
390 // default kept for that degenerate case.
391 Code::FailedPrecondition => Self::NotRunning { detail },
392 // ABORTED deliberately falls through to Server: the server sends
393 // it only for `sequence_conflict`, an internal single-writer
394 // invariant violation (a double-writer bug), never an
395 // idempotency conflict — so it must not map to AlreadyExists.
396 _ => Self::Server { detail },
397 }
398 }
399
400 /// Converts a tonic transport failure into the client SDK taxonomy,
401 /// preserving the full transport error chain as the detail message.
402 #[must_use]
403 pub fn from_transport_error(error: &tonic::transport::Error) -> Self {
404 Self::Unavailable {
405 detail: ErrorDetail::new(source_chain(error)),
406 }
407 }
408}
409
410/// Joins an error's Display with every `source()` cause, so transport errors
411/// like tonic's bare "transport error" keep their underlying connect/DNS/TLS
412/// detail.
413fn source_chain(error: &(dyn std::error::Error + 'static)) -> String {
414 let mut message = error.to_string();
415 let mut source = error.source();
416 while let Some(cause) = source {
417 message.push_str(": ");
418 message.push_str(&cause.to_string());
419 source = cause.source();
420 }
421 message
422}
423
424fn decode_status_details(status: &tonic::Status) -> Option<ProtoWireError> {
425 let details = status.details();
426 if details.is_empty() {
427 return None;
428 }
429 ProtoWireError::decode(details).ok()
430}
431
432/// Unit tests live in a sibling file so this module stays under the 500-line
433/// law with the test bodies intact rather than thinned.
434#[cfg(test)]
435#[path = "error_tests.rs"]
436mod tests;