Skip to main content

aion_proto/
error.rs

1//! `WireError` taxonomy and mapping.
2//!
3//! `WireErrorCode` is the only client-branchable failure contract. The
4//! associated message is informational and may change without notice.
5//!
6//! Authoritative mapping table for adapters that can see engine/store types:
7//! - `aion_store::StoreError::SequenceConflict` -> `SequenceConflict`.
8//! - `aion_store::StoreError::NotFound` -> `NotFound`.
9//! - `aion_store::StoreError::Backend | Serialization` -> `Backend`.
10//! - `aion::EngineError::WorkflowNotFound` -> `NotFound`.
11//! - `aion::EngineError::Store | Durability(StoreError)` -> store mapping above.
12//! - Other operational engine failures -> `Backend`.
13//! - Query unknown/timeout/not-running/unknown-workflow ->
14//!   `UnknownQuery`/`QueryTimeout`/`NotRunning`/`NotFound`.
15//! - Query handler ran and reported an application-level failure ->
16//!   `QueryFailed`. Query reply dropped because the workflow ended first ->
17//!   `NotRunning`.
18//! - Signal terminal/unknown target -> `NotRunning`/`NotFound`.
19//! - Namespace authorization failure -> `NamespaceDenied`.
20//! - Bounded subscriber overflow -> `Lagged`.
21//!
22//! This crate intentionally does not depend on `aion` or `aion-store` to keep
23//! the proto crate leaf-safe; server-side adapters apply this documented table
24//! where those concrete error types are reachable.
25
26use std::fmt;
27
28use serde::{Deserialize, Serialize};
29
30/// Stable, closed, client-branchable wire error codes.
31///
32/// The JSON representation is the `snake_case` code returned by
33/// [`WireErrorCode::as_str`] — the documented stable contract every SDK wire
34/// map branches on. `rename_all = "snake_case"` keeps Serialize/Deserialize
35/// byte-identical to `as_str()` for every variant; the
36/// `json_codes_match_as_str_and_round_trip` pin test enforces this.
37#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
38#[serde(rename_all = "snake_case")]
39pub enum WireErrorCode {
40    /// The requested workflow, run, activity, timer, or history item was not found.
41    NotFound,
42    /// The caller is not authorized to operate in the requested namespace.
43    NamespaceDenied,
44    /// A durable write lost an optimistic sequence-position race.
45    SequenceConflict,
46    /// The requested workflow query name is not registered.
47    UnknownQuery,
48    /// A workflow query exceeded its configured timeout/window.
49    QueryTimeout,
50    /// The target workflow is terminal or otherwise not running.
51    NotRunning,
52    /// A bounded stream consumer fell behind and was disconnected.
53    Lagged,
54    /// A request body, identifier, or envelope is malformed or semantically invalid.
55    InvalidInput,
56    /// Backend storage, serialization, runtime, or other internal failure.
57    Backend,
58    /// The workflow's query handler ran and reported an application-level failure.
59    QueryFailed,
60    /// The caller is not authorized to use the operator deploy surface.
61    DeployDenied,
62    /// A deploy unload/route was refused because the version is route-active
63    /// or pinned by live state.
64    VersionPinned,
65}
66
67impl WireErrorCode {
68    /// Returns the stable string code SDKs may branch on.
69    #[must_use]
70    pub const fn as_str(self) -> &'static str {
71        match self {
72            Self::NotFound => "not_found",
73            Self::NamespaceDenied => "namespace_denied",
74            Self::SequenceConflict => "sequence_conflict",
75            Self::UnknownQuery => "unknown_query",
76            Self::QueryTimeout => "query_timeout",
77            Self::NotRunning => "not_running",
78            Self::Lagged => "lagged",
79            Self::InvalidInput => "invalid_input",
80            Self::Backend => "backend",
81            Self::QueryFailed => "query_failed",
82            Self::DeployDenied => "deploy_denied",
83            Self::VersionPinned => "version_pinned",
84        }
85    }
86}
87
88impl fmt::Display for WireErrorCode {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        formatter.write_str(self.as_str())
91    }
92}
93
94/// Wire-safe error value. `code` is stable; `message` is informational only.
95#[derive(thiserror::Error, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
96#[error("{code}: {message}")]
97pub struct WireError {
98    /// Stable client-branchable error code.
99    pub code: WireErrorCode,
100    /// Human-readable informational message.
101    pub message: String,
102    /// Concrete typed error variant, when the server can expose one safely.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub error_type: Option<String>,
105}
106
107impl WireError {
108    /// Creates a wire error with the supplied stable code and informational message.
109    #[must_use]
110    pub fn new(code: WireErrorCode, message: impl Into<String>) -> Self {
111        Self {
112            code,
113            message: message.into(),
114            error_type: None,
115        }
116    }
117
118    /// Attach a concrete typed error variant name to this wire error.
119    #[must_use]
120    pub fn with_error_type(mut self, error_type: impl Into<String>) -> Self {
121        self.error_type = Some(error_type.into());
122        self
123    }
124
125    /// Attach an optional concrete typed error variant name to this wire error.
126    #[must_use]
127    pub fn with_optional_error_type(mut self, error_type: Option<String>) -> Self {
128        self.error_type = error_type;
129        self
130    }
131
132    /// Creates a wire error with a concrete typed error variant name.
133    #[must_use]
134    pub fn new_with_type(
135        code: WireErrorCode,
136        error_type: impl Into<String>,
137        message: impl Into<String>,
138    ) -> Self {
139        Self::new(code, message).with_error_type(error_type)
140    }
141
142    /// Not-found failure.
143    #[must_use]
144    pub fn not_found(message: impl Into<String>) -> Self {
145        Self::new(WireErrorCode::NotFound, message)
146    }
147
148    /// Namespace authorization failure.
149    #[must_use]
150    pub fn namespace_denied(message: impl Into<String>) -> Self {
151        Self::new(WireErrorCode::NamespaceDenied, message)
152    }
153
154    /// Durable sequence conflict failure.
155    #[must_use]
156    pub fn sequence_conflict(message: impl Into<String>) -> Self {
157        Self::new(WireErrorCode::SequenceConflict, message)
158    }
159
160    /// Unknown workflow query failure.
161    #[must_use]
162    pub fn unknown_query(message: impl Into<String>) -> Self {
163        Self::new(WireErrorCode::UnknownQuery, message)
164    }
165
166    /// Query timeout failure.
167    #[must_use]
168    pub fn query_timeout(message: impl Into<String>) -> Self {
169        Self::new(WireErrorCode::QueryTimeout, message)
170    }
171
172    /// Workflow not-running failure.
173    #[must_use]
174    pub fn not_running(message: impl Into<String>) -> Self {
175        Self::new(WireErrorCode::NotRunning, message)
176    }
177
178    /// Lagged stream failure.
179    #[must_use]
180    pub fn lagged(message: impl Into<String>) -> Self {
181        Self::new(WireErrorCode::Lagged, message)
182    }
183
184    /// Invalid input failure.
185    #[must_use]
186    pub fn invalid_input(message: impl Into<String>) -> Self {
187        Self::new(WireErrorCode::InvalidInput, message)
188    }
189
190    /// Backend/internal failure.
191    #[must_use]
192    pub fn backend(message: impl Into<String>) -> Self {
193        Self::new(WireErrorCode::Backend, message)
194    }
195
196    /// Query-handler application-level failure.
197    #[must_use]
198    pub fn query_failed(message: impl Into<String>) -> Self {
199        Self::new(WireErrorCode::QueryFailed, message)
200    }
201
202    /// Deploy authorization failure.
203    #[must_use]
204    pub fn deploy_denied(message: impl Into<String>) -> Self {
205        Self::new(WireErrorCode::DeployDenied, message)
206    }
207
208    /// Deploy version-pinned refusal.
209    #[must_use]
210    pub fn version_pinned(message: impl Into<String>) -> Self {
211        Self::new(WireErrorCode::VersionPinned, message)
212    }
213
214    /// Not-found failure with a concrete typed error variant name.
215    #[must_use]
216    pub fn not_found_with_type(error_type: impl Into<String>, message: impl Into<String>) -> Self {
217        Self::new_with_type(WireErrorCode::NotFound, error_type, message)
218    }
219
220    /// Not-running failure with a concrete typed error variant name.
221    #[must_use]
222    pub fn not_running_with_type(
223        error_type: impl Into<String>,
224        message: impl Into<String>,
225    ) -> Self {
226        Self::new_with_type(WireErrorCode::NotRunning, error_type, message)
227    }
228
229    /// Backend/internal failure with a concrete typed error variant name.
230    #[must_use]
231    pub fn backend_with_type(error_type: impl Into<String>, message: impl Into<String>) -> Self {
232        Self::new_with_type(WireErrorCode::Backend, error_type, message)
233    }
234}
235
236/// Proto representation of [`WireErrorCode`]. Zero is invalid on decode.
237#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, prost::Enumeration)]
238#[repr(i32)]
239pub enum ProtoWireErrorCode {
240    /// Missing/invalid code.
241    Unspecified = 0,
242    /// See [`WireErrorCode::NotFound`].
243    NotFound = 1,
244    /// See [`WireErrorCode::NamespaceDenied`].
245    NamespaceDenied = 2,
246    /// See [`WireErrorCode::SequenceConflict`].
247    SequenceConflict = 3,
248    /// See [`WireErrorCode::UnknownQuery`].
249    UnknownQuery = 4,
250    /// See [`WireErrorCode::QueryTimeout`].
251    QueryTimeout = 5,
252    /// See [`WireErrorCode::NotRunning`].
253    NotRunning = 6,
254    /// See [`WireErrorCode::Lagged`].
255    Lagged = 7,
256    /// See [`WireErrorCode::InvalidInput`].
257    InvalidInput = 8,
258    /// See [`WireErrorCode::Backend`].
259    Backend = 9,
260    /// See [`WireErrorCode::QueryFailed`].
261    QueryFailed = 10,
262    /// See [`WireErrorCode::DeployDenied`].
263    DeployDenied = 11,
264    /// See [`WireErrorCode::VersionPinned`].
265    VersionPinned = 12,
266}
267
268/// Proto representation of [`WireError`].
269#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, prost::Message)]
270pub struct ProtoWireError {
271    /// Stable client-branchable code.
272    #[prost(enumeration = "ProtoWireErrorCode", tag = "1")]
273    pub code: i32,
274    /// Informational message.
275    #[prost(string, tag = "2")]
276    pub message: String,
277    /// Concrete typed error variant, when known.
278    #[prost(string, optional, tag = "3")]
279    pub error_type: Option<String>,
280}
281
282impl From<WireErrorCode> for ProtoWireErrorCode {
283    fn from(value: WireErrorCode) -> Self {
284        match value {
285            WireErrorCode::NotFound => Self::NotFound,
286            WireErrorCode::NamespaceDenied => Self::NamespaceDenied,
287            WireErrorCode::SequenceConflict => Self::SequenceConflict,
288            WireErrorCode::UnknownQuery => Self::UnknownQuery,
289            WireErrorCode::QueryTimeout => Self::QueryTimeout,
290            WireErrorCode::NotRunning => Self::NotRunning,
291            WireErrorCode::Lagged => Self::Lagged,
292            WireErrorCode::InvalidInput => Self::InvalidInput,
293            WireErrorCode::Backend => Self::Backend,
294            WireErrorCode::QueryFailed => Self::QueryFailed,
295            WireErrorCode::DeployDenied => Self::DeployDenied,
296            WireErrorCode::VersionPinned => Self::VersionPinned,
297        }
298    }
299}
300
301impl TryFrom<ProtoWireErrorCode> for WireErrorCode {
302    type Error = WireError;
303
304    fn try_from(value: ProtoWireErrorCode) -> Result<Self, Self::Error> {
305        match value {
306            ProtoWireErrorCode::Unspecified => {
307                Err(WireError::backend("wire error code is missing"))
308            }
309            ProtoWireErrorCode::NotFound => Ok(Self::NotFound),
310            ProtoWireErrorCode::NamespaceDenied => Ok(Self::NamespaceDenied),
311            ProtoWireErrorCode::SequenceConflict => Ok(Self::SequenceConflict),
312            ProtoWireErrorCode::UnknownQuery => Ok(Self::UnknownQuery),
313            ProtoWireErrorCode::QueryTimeout => Ok(Self::QueryTimeout),
314            ProtoWireErrorCode::NotRunning => Ok(Self::NotRunning),
315            ProtoWireErrorCode::Lagged => Ok(Self::Lagged),
316            ProtoWireErrorCode::InvalidInput => Ok(Self::InvalidInput),
317            ProtoWireErrorCode::Backend => Ok(Self::Backend),
318            ProtoWireErrorCode::QueryFailed => Ok(Self::QueryFailed),
319            ProtoWireErrorCode::DeployDenied => Ok(Self::DeployDenied),
320            ProtoWireErrorCode::VersionPinned => Ok(Self::VersionPinned),
321        }
322    }
323}
324
325impl From<WireError> for ProtoWireError {
326    fn from(value: WireError) -> Self {
327        let code = ProtoWireErrorCode::from(value.code) as i32;
328        Self {
329            code,
330            message: value.message,
331            error_type: value.error_type,
332        }
333    }
334}
335
336impl TryFrom<ProtoWireError> for WireError {
337    type Error = WireError;
338
339    fn try_from(value: ProtoWireError) -> Result<Self, Self::Error> {
340        let code = ProtoWireErrorCode::try_from(value.code)
341            .map_err(|_| WireError::backend("wire error code is unknown"))?;
342        Ok(Self::new(WireErrorCode::try_from(code)?, value.message)
343            .with_optional_error_type(value.error_type))
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::{ProtoWireError, ProtoWireErrorCode, WireError, WireErrorCode};
350
351    fn assert_send_sync<T: Send + Sync>() {}
352
353    /// Exhaustive successor chain over [`WireErrorCode`]. Adding a variant
354    /// makes this match non-exhaustive, so the build breaks until the new
355    /// variant is threaded into the chain and therefore into every test that
356    /// iterates [`all_codes`]. This is deliberately not a hand-maintained
357    /// list.
358    const fn next_code(code: WireErrorCode) -> Option<WireErrorCode> {
359        match code {
360            WireErrorCode::NotFound => Some(WireErrorCode::NamespaceDenied),
361            WireErrorCode::NamespaceDenied => Some(WireErrorCode::SequenceConflict),
362            WireErrorCode::SequenceConflict => Some(WireErrorCode::UnknownQuery),
363            WireErrorCode::UnknownQuery => Some(WireErrorCode::QueryTimeout),
364            WireErrorCode::QueryTimeout => Some(WireErrorCode::NotRunning),
365            WireErrorCode::NotRunning => Some(WireErrorCode::Lagged),
366            WireErrorCode::Lagged => Some(WireErrorCode::InvalidInput),
367            WireErrorCode::InvalidInput => Some(WireErrorCode::Backend),
368            WireErrorCode::Backend => Some(WireErrorCode::QueryFailed),
369            WireErrorCode::QueryFailed => Some(WireErrorCode::DeployDenied),
370            WireErrorCode::DeployDenied => Some(WireErrorCode::VersionPinned),
371            WireErrorCode::VersionPinned => None,
372        }
373    }
374
375    /// Every wire error code, derived from the compile-breaking chain above.
376    fn all_codes() -> Vec<WireErrorCode> {
377        let mut codes = vec![WireErrorCode::NotFound];
378        while let Some(&last) = codes.last() {
379            match next_code(last) {
380                Some(next) => codes.push(next),
381                None => break,
382            }
383        }
384        codes
385    }
386
387    #[test]
388    fn wire_error_is_send_sync() {
389        assert_send_sync::<WireError>();
390    }
391
392    /// The numeric proto enum values are the cross-SDK wire contract: every
393    /// generated decoder (Python, TypeScript, gRPC stubs) branches on these
394    /// exact integers, so each variant's number is pinned explicitly.
395    #[test]
396    fn proto_numeric_values_are_pinned() {
397        let expected: &[(WireErrorCode, i32)] = &[
398            (WireErrorCode::NotFound, 1),
399            (WireErrorCode::NamespaceDenied, 2),
400            (WireErrorCode::SequenceConflict, 3),
401            (WireErrorCode::UnknownQuery, 4),
402            (WireErrorCode::QueryTimeout, 5),
403            (WireErrorCode::NotRunning, 6),
404            (WireErrorCode::Lagged, 7),
405            (WireErrorCode::InvalidInput, 8),
406            (WireErrorCode::Backend, 9),
407            (WireErrorCode::QueryFailed, 10),
408            (WireErrorCode::DeployDenied, 11),
409            (WireErrorCode::VersionPinned, 12),
410        ];
411        assert_eq!(
412            expected.len(),
413            all_codes().len(),
414            "every WireErrorCode variant must have a pinned numeric value"
415        );
416        for &(code, number) in expected {
417            assert_eq!(
418                ProtoWireErrorCode::from(code) as i32,
419                number,
420                "{code:?} must keep proto enum value {number}",
421            );
422        }
423    }
424
425    /// The `snake_case` string codes are the JSON wire contract every SDK
426    /// branches on; each one is pinned explicitly.
427    #[test]
428    fn string_codes_are_pinned() {
429        let expected: &[(WireErrorCode, &str)] = &[
430            (WireErrorCode::NotFound, "not_found"),
431            (WireErrorCode::NamespaceDenied, "namespace_denied"),
432            (WireErrorCode::SequenceConflict, "sequence_conflict"),
433            (WireErrorCode::UnknownQuery, "unknown_query"),
434            (WireErrorCode::QueryTimeout, "query_timeout"),
435            (WireErrorCode::NotRunning, "not_running"),
436            (WireErrorCode::Lagged, "lagged"),
437            (WireErrorCode::InvalidInput, "invalid_input"),
438            (WireErrorCode::Backend, "backend"),
439            (WireErrorCode::QueryFailed, "query_failed"),
440            (WireErrorCode::DeployDenied, "deploy_denied"),
441            (WireErrorCode::VersionPinned, "version_pinned"),
442        ];
443        assert_eq!(
444            expected.len(),
445            all_codes().len(),
446            "every WireErrorCode variant must have a pinned string code"
447        );
448        for &(code, string) in expected {
449            assert_eq!(code.as_str(), string, "{code:?} must keep code {string}");
450        }
451    }
452
453    #[test]
454    fn json_codes_match_as_str_and_round_trip() -> Result<(), serde_json::Error> {
455        for code in all_codes() {
456            let serialized = serde_json::to_value(code)?;
457            assert_eq!(
458                serialized,
459                serde_json::Value::String(code.as_str().to_owned()),
460                "JSON serialization of {code:?} must equal as_str()",
461            );
462            let deserialized: WireErrorCode =
463                serde_json::from_value(serde_json::Value::String(code.as_str().to_owned()))?;
464            assert_eq!(deserialized, code, "{code:?} must round-trip through JSON");
465
466            let error = WireError::new(code, format!("message for {}", code.as_str()));
467            let body = serde_json::to_value(&error)?;
468            assert_eq!(
469                body.get("code"),
470                Some(&serde_json::Value::String(code.as_str().to_owned())),
471                "WireError JSON body must carry the snake_case code for {code:?}",
472            );
473            let decoded: WireError = serde_json::from_value(body)?;
474            assert_eq!(decoded, error);
475        }
476        Ok(())
477    }
478
479    #[test]
480    fn proto_round_trips_every_code() -> Result<(), WireError> {
481        for code in all_codes() {
482            let error = WireError::new_with_type(
483                code,
484                format!("{}Variant", code.as_str()),
485                format!("message for {}", code.as_str()),
486            );
487            let proto = ProtoWireError::from(error.clone());
488            let decoded = WireError::try_from(proto)?;
489            assert_eq!(decoded, error);
490        }
491
492        Ok(())
493    }
494
495    #[test]
496    fn rejects_unspecified_proto_code() {
497        let proto = ProtoWireError {
498            code: 0,
499            message: String::from("missing"),
500            error_type: None,
501        };
502
503        let result = WireError::try_from(proto);
504        assert_eq!(
505            result,
506            Err(WireError::backend("wire error code is missing"))
507        );
508    }
509
510    #[test]
511    fn representative_documented_mappings_use_stable_codes() {
512        let engine_unknown_workflow = WireError::not_found("workflow was not found");
513        let store_sequence_conflict = WireError::sequence_conflict("event sequence conflicted");
514
515        assert_eq!(engine_unknown_workflow.code, WireErrorCode::NotFound);
516        assert_eq!(
517            store_sequence_conflict.code,
518            WireErrorCode::SequenceConflict
519        );
520        assert_eq!(
521            WireError::namespace_denied("denied").code,
522            WireErrorCode::NamespaceDenied
523        );
524        assert_eq!(
525            WireError::query_timeout("timeout").code,
526            WireErrorCode::QueryTimeout
527        );
528        assert_eq!(
529            WireError::unknown_query("unknown").code,
530            WireErrorCode::UnknownQuery
531        );
532        assert_eq!(
533            WireError::not_running("terminal").code,
534            WireErrorCode::NotRunning
535        );
536        assert_eq!(
537            WireError::invalid_input("malformed").code,
538            WireErrorCode::InvalidInput
539        );
540        assert_eq!(
541            WireError::query_failed("handler raised").code,
542            WireErrorCode::QueryFailed
543        );
544        assert_eq!(
545            WireError::deploy_denied("no deploy grant").code,
546            WireErrorCode::DeployDenied
547        );
548        assert_eq!(
549            WireError::version_pinned("pinned by live run").code,
550            WireErrorCode::VersionPinned
551        );
552    }
553}