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    /// The targeted shard is owned by a different cluster node; the request was
66    /// fenced. A retryable routing signal: the caller (or the request-routing
67    /// edge) should re-resolve the shard owner and retry or forward.
68    NotOwner,
69    /// A precondition on the target's current state was not met (e.g. a reopen
70    /// of a run that is not a reopenable terminal). Distinct from `NotFound`
71    /// (absent) and `Backend` (internal failure): the target exists but is in
72    /// the wrong state. Maps to gRPC `FailedPrecondition` / HTTP 409 Conflict.
73    InvalidState,
74}
75
76impl WireErrorCode {
77    /// Returns the stable string code SDKs may branch on.
78    #[must_use]
79    pub const fn as_str(self) -> &'static str {
80        match self {
81            Self::NotFound => "not_found",
82            Self::NamespaceDenied => "namespace_denied",
83            Self::SequenceConflict => "sequence_conflict",
84            Self::UnknownQuery => "unknown_query",
85            Self::QueryTimeout => "query_timeout",
86            Self::NotRunning => "not_running",
87            Self::Lagged => "lagged",
88            Self::InvalidInput => "invalid_input",
89            Self::Backend => "backend",
90            Self::QueryFailed => "query_failed",
91            Self::DeployDenied => "deploy_denied",
92            Self::VersionPinned => "version_pinned",
93            Self::NotOwner => "not_owner",
94            Self::InvalidState => "invalid_state",
95        }
96    }
97}
98
99impl fmt::Display for WireErrorCode {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        formatter.write_str(self.as_str())
102    }
103}
104
105/// Wire-safe error value. `code` is stable; `message` is informational only.
106#[derive(thiserror::Error, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
107#[error("{code}: {message}")]
108pub struct WireError {
109    /// Stable client-branchable error code.
110    pub code: WireErrorCode,
111    /// Human-readable informational message.
112    pub message: String,
113    /// Concrete typed error variant, when the server can expose one safely.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub error_type: Option<String>,
116}
117
118impl WireError {
119    /// Creates a wire error with the supplied stable code and informational message.
120    #[must_use]
121    pub fn new(code: WireErrorCode, message: impl Into<String>) -> Self {
122        Self {
123            code,
124            message: message.into(),
125            error_type: None,
126        }
127    }
128
129    /// Attach a concrete typed error variant name to this wire error.
130    #[must_use]
131    pub fn with_error_type(mut self, error_type: impl Into<String>) -> Self {
132        self.error_type = Some(error_type.into());
133        self
134    }
135
136    /// Attach an optional concrete typed error variant name to this wire error.
137    #[must_use]
138    pub fn with_optional_error_type(mut self, error_type: Option<String>) -> Self {
139        self.error_type = error_type;
140        self
141    }
142
143    /// Creates a wire error with a concrete typed error variant name.
144    #[must_use]
145    pub fn new_with_type(
146        code: WireErrorCode,
147        error_type: impl Into<String>,
148        message: impl Into<String>,
149    ) -> Self {
150        Self::new(code, message).with_error_type(error_type)
151    }
152
153    /// Not-found failure.
154    #[must_use]
155    pub fn not_found(message: impl Into<String>) -> Self {
156        Self::new(WireErrorCode::NotFound, message)
157    }
158
159    /// Namespace authorization failure.
160    #[must_use]
161    pub fn namespace_denied(message: impl Into<String>) -> Self {
162        Self::new(WireErrorCode::NamespaceDenied, message)
163    }
164
165    /// Durable sequence conflict failure.
166    #[must_use]
167    pub fn sequence_conflict(message: impl Into<String>) -> Self {
168        Self::new(WireErrorCode::SequenceConflict, message)
169    }
170
171    /// Unknown workflow query failure.
172    #[must_use]
173    pub fn unknown_query(message: impl Into<String>) -> Self {
174        Self::new(WireErrorCode::UnknownQuery, message)
175    }
176
177    /// Query timeout failure.
178    #[must_use]
179    pub fn query_timeout(message: impl Into<String>) -> Self {
180        Self::new(WireErrorCode::QueryTimeout, message)
181    }
182
183    /// Workflow not-running failure.
184    #[must_use]
185    pub fn not_running(message: impl Into<String>) -> Self {
186        Self::new(WireErrorCode::NotRunning, message)
187    }
188
189    /// Lagged stream failure.
190    #[must_use]
191    pub fn lagged(message: impl Into<String>) -> Self {
192        Self::new(WireErrorCode::Lagged, message)
193    }
194
195    /// Invalid input failure.
196    #[must_use]
197    pub fn invalid_input(message: impl Into<String>) -> Self {
198        Self::new(WireErrorCode::InvalidInput, message)
199    }
200
201    /// Backend/internal failure.
202    #[must_use]
203    pub fn backend(message: impl Into<String>) -> Self {
204        Self::new(WireErrorCode::Backend, message)
205    }
206
207    /// Query-handler application-level failure.
208    #[must_use]
209    pub fn query_failed(message: impl Into<String>) -> Self {
210        Self::new(WireErrorCode::QueryFailed, message)
211    }
212
213    /// Deploy authorization failure.
214    #[must_use]
215    pub fn deploy_denied(message: impl Into<String>) -> Self {
216        Self::new(WireErrorCode::DeployDenied, message)
217    }
218
219    /// Deploy version-pinned refusal.
220    #[must_use]
221    pub fn version_pinned(message: impl Into<String>) -> Self {
222        Self::new(WireErrorCode::VersionPinned, message)
223    }
224
225    /// Wrong-shard-owner (fenced) failure — retryable routing signal.
226    #[must_use]
227    pub fn not_owner(message: impl Into<String>) -> Self {
228        Self::new(WireErrorCode::NotOwner, message)
229    }
230
231    /// Invalid-state precondition failure.
232    #[must_use]
233    pub fn invalid_state(message: impl Into<String>) -> Self {
234        Self::new(WireErrorCode::InvalidState, message)
235    }
236
237    /// Invalid-state precondition failure with a concrete typed error variant name.
238    #[must_use]
239    pub fn invalid_state_with_type(
240        error_type: impl Into<String>,
241        message: impl Into<String>,
242    ) -> Self {
243        Self::new_with_type(WireErrorCode::InvalidState, error_type, message)
244    }
245
246    /// Not-found failure with a concrete typed error variant name.
247    #[must_use]
248    pub fn not_found_with_type(error_type: impl Into<String>, message: impl Into<String>) -> Self {
249        Self::new_with_type(WireErrorCode::NotFound, error_type, message)
250    }
251
252    /// Not-running failure with a concrete typed error variant name.
253    #[must_use]
254    pub fn not_running_with_type(
255        error_type: impl Into<String>,
256        message: impl Into<String>,
257    ) -> Self {
258        Self::new_with_type(WireErrorCode::NotRunning, error_type, message)
259    }
260
261    /// Backend/internal failure with a concrete typed error variant name.
262    #[must_use]
263    pub fn backend_with_type(error_type: impl Into<String>, message: impl Into<String>) -> Self {
264        Self::new_with_type(WireErrorCode::Backend, error_type, message)
265    }
266}
267
268/// Proto representation of [`WireErrorCode`]. Zero is invalid on decode.
269#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, prost::Enumeration)]
270#[repr(i32)]
271pub enum ProtoWireErrorCode {
272    /// Missing/invalid code.
273    Unspecified = 0,
274    /// See [`WireErrorCode::NotFound`].
275    NotFound = 1,
276    /// See [`WireErrorCode::NamespaceDenied`].
277    NamespaceDenied = 2,
278    /// See [`WireErrorCode::SequenceConflict`].
279    SequenceConflict = 3,
280    /// See [`WireErrorCode::UnknownQuery`].
281    UnknownQuery = 4,
282    /// See [`WireErrorCode::QueryTimeout`].
283    QueryTimeout = 5,
284    /// See [`WireErrorCode::NotRunning`].
285    NotRunning = 6,
286    /// See [`WireErrorCode::Lagged`].
287    Lagged = 7,
288    /// See [`WireErrorCode::InvalidInput`].
289    InvalidInput = 8,
290    /// See [`WireErrorCode::Backend`].
291    Backend = 9,
292    /// See [`WireErrorCode::QueryFailed`].
293    QueryFailed = 10,
294    /// See [`WireErrorCode::DeployDenied`].
295    DeployDenied = 11,
296    /// See [`WireErrorCode::VersionPinned`].
297    VersionPinned = 12,
298    /// See [`WireErrorCode::NotOwner`].
299    NotOwner = 13,
300    /// See [`WireErrorCode::InvalidState`].
301    InvalidState = 14,
302}
303
304/// Proto representation of [`WireError`].
305#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, prost::Message)]
306pub struct ProtoWireError {
307    /// Stable client-branchable code.
308    #[prost(enumeration = "ProtoWireErrorCode", tag = "1")]
309    pub code: i32,
310    /// Informational message.
311    #[prost(string, tag = "2")]
312    pub message: String,
313    /// Concrete typed error variant, when known.
314    #[prost(string, optional, tag = "3")]
315    pub error_type: Option<String>,
316}
317
318impl From<WireErrorCode> for ProtoWireErrorCode {
319    fn from(value: WireErrorCode) -> Self {
320        match value {
321            WireErrorCode::NotFound => Self::NotFound,
322            WireErrorCode::NamespaceDenied => Self::NamespaceDenied,
323            WireErrorCode::SequenceConflict => Self::SequenceConflict,
324            WireErrorCode::UnknownQuery => Self::UnknownQuery,
325            WireErrorCode::QueryTimeout => Self::QueryTimeout,
326            WireErrorCode::NotRunning => Self::NotRunning,
327            WireErrorCode::Lagged => Self::Lagged,
328            WireErrorCode::InvalidInput => Self::InvalidInput,
329            WireErrorCode::Backend => Self::Backend,
330            WireErrorCode::QueryFailed => Self::QueryFailed,
331            WireErrorCode::DeployDenied => Self::DeployDenied,
332            WireErrorCode::VersionPinned => Self::VersionPinned,
333            WireErrorCode::NotOwner => Self::NotOwner,
334            WireErrorCode::InvalidState => Self::InvalidState,
335        }
336    }
337}
338
339impl TryFrom<ProtoWireErrorCode> for WireErrorCode {
340    type Error = WireError;
341
342    fn try_from(value: ProtoWireErrorCode) -> Result<Self, Self::Error> {
343        match value {
344            ProtoWireErrorCode::Unspecified => {
345                Err(WireError::backend("wire error code is missing"))
346            }
347            ProtoWireErrorCode::NotFound => Ok(Self::NotFound),
348            ProtoWireErrorCode::NamespaceDenied => Ok(Self::NamespaceDenied),
349            ProtoWireErrorCode::SequenceConflict => Ok(Self::SequenceConflict),
350            ProtoWireErrorCode::UnknownQuery => Ok(Self::UnknownQuery),
351            ProtoWireErrorCode::QueryTimeout => Ok(Self::QueryTimeout),
352            ProtoWireErrorCode::NotRunning => Ok(Self::NotRunning),
353            ProtoWireErrorCode::Lagged => Ok(Self::Lagged),
354            ProtoWireErrorCode::InvalidInput => Ok(Self::InvalidInput),
355            ProtoWireErrorCode::Backend => Ok(Self::Backend),
356            ProtoWireErrorCode::QueryFailed => Ok(Self::QueryFailed),
357            ProtoWireErrorCode::DeployDenied => Ok(Self::DeployDenied),
358            ProtoWireErrorCode::VersionPinned => Ok(Self::VersionPinned),
359            ProtoWireErrorCode::NotOwner => Ok(Self::NotOwner),
360            ProtoWireErrorCode::InvalidState => Ok(Self::InvalidState),
361        }
362    }
363}
364
365impl From<WireError> for ProtoWireError {
366    fn from(value: WireError) -> Self {
367        let code = ProtoWireErrorCode::from(value.code) as i32;
368        Self {
369            code,
370            message: value.message,
371            error_type: value.error_type,
372        }
373    }
374}
375
376impl TryFrom<ProtoWireError> for WireError {
377    type Error = WireError;
378
379    fn try_from(value: ProtoWireError) -> Result<Self, Self::Error> {
380        let code = ProtoWireErrorCode::try_from(value.code)
381            .map_err(|_| WireError::backend("wire error code is unknown"))?;
382        Ok(Self::new(WireErrorCode::try_from(code)?, value.message)
383            .with_optional_error_type(value.error_type))
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::{ProtoWireError, ProtoWireErrorCode, WireError, WireErrorCode};
390
391    fn assert_send_sync<T: Send + Sync>() {}
392
393    /// Exhaustive successor chain over [`WireErrorCode`]. Adding a variant
394    /// makes this match non-exhaustive, so the build breaks until the new
395    /// variant is threaded into the chain and therefore into every test that
396    /// iterates [`all_codes`]. This is deliberately not a hand-maintained
397    /// list.
398    const fn next_code(code: WireErrorCode) -> Option<WireErrorCode> {
399        match code {
400            WireErrorCode::NotFound => Some(WireErrorCode::NamespaceDenied),
401            WireErrorCode::NamespaceDenied => Some(WireErrorCode::SequenceConflict),
402            WireErrorCode::SequenceConflict => Some(WireErrorCode::UnknownQuery),
403            WireErrorCode::UnknownQuery => Some(WireErrorCode::QueryTimeout),
404            WireErrorCode::QueryTimeout => Some(WireErrorCode::NotRunning),
405            WireErrorCode::NotRunning => Some(WireErrorCode::Lagged),
406            WireErrorCode::Lagged => Some(WireErrorCode::InvalidInput),
407            WireErrorCode::InvalidInput => Some(WireErrorCode::Backend),
408            WireErrorCode::Backend => Some(WireErrorCode::QueryFailed),
409            WireErrorCode::QueryFailed => Some(WireErrorCode::DeployDenied),
410            WireErrorCode::DeployDenied => Some(WireErrorCode::VersionPinned),
411            WireErrorCode::VersionPinned => Some(WireErrorCode::NotOwner),
412            WireErrorCode::NotOwner => Some(WireErrorCode::InvalidState),
413            WireErrorCode::InvalidState => None,
414        }
415    }
416
417    /// Every wire error code, derived from the compile-breaking chain above.
418    fn all_codes() -> Vec<WireErrorCode> {
419        let mut codes = vec![WireErrorCode::NotFound];
420        while let Some(&last) = codes.last() {
421            match next_code(last) {
422                Some(next) => codes.push(next),
423                None => break,
424            }
425        }
426        codes
427    }
428
429    #[test]
430    fn wire_error_is_send_sync() {
431        assert_send_sync::<WireError>();
432    }
433
434    /// The numeric proto enum values are the cross-SDK wire contract: every
435    /// generated decoder (Python, TypeScript, gRPC stubs) branches on these
436    /// exact integers, so each variant's number is pinned explicitly.
437    #[test]
438    fn proto_numeric_values_are_pinned() {
439        let expected: &[(WireErrorCode, i32)] = &[
440            (WireErrorCode::NotFound, 1),
441            (WireErrorCode::NamespaceDenied, 2),
442            (WireErrorCode::SequenceConflict, 3),
443            (WireErrorCode::UnknownQuery, 4),
444            (WireErrorCode::QueryTimeout, 5),
445            (WireErrorCode::NotRunning, 6),
446            (WireErrorCode::Lagged, 7),
447            (WireErrorCode::InvalidInput, 8),
448            (WireErrorCode::Backend, 9),
449            (WireErrorCode::QueryFailed, 10),
450            (WireErrorCode::DeployDenied, 11),
451            (WireErrorCode::VersionPinned, 12),
452            (WireErrorCode::NotOwner, 13),
453            (WireErrorCode::InvalidState, 14),
454        ];
455        assert_eq!(
456            expected.len(),
457            all_codes().len(),
458            "every WireErrorCode variant must have a pinned numeric value"
459        );
460        for &(code, number) in expected {
461            assert_eq!(
462                ProtoWireErrorCode::from(code) as i32,
463                number,
464                "{code:?} must keep proto enum value {number}",
465            );
466        }
467    }
468
469    /// The `snake_case` string codes are the JSON wire contract every SDK
470    /// branches on; each one is pinned explicitly.
471    #[test]
472    fn string_codes_are_pinned() {
473        let expected: &[(WireErrorCode, &str)] = &[
474            (WireErrorCode::NotFound, "not_found"),
475            (WireErrorCode::NamespaceDenied, "namespace_denied"),
476            (WireErrorCode::SequenceConflict, "sequence_conflict"),
477            (WireErrorCode::UnknownQuery, "unknown_query"),
478            (WireErrorCode::QueryTimeout, "query_timeout"),
479            (WireErrorCode::NotRunning, "not_running"),
480            (WireErrorCode::Lagged, "lagged"),
481            (WireErrorCode::InvalidInput, "invalid_input"),
482            (WireErrorCode::Backend, "backend"),
483            (WireErrorCode::QueryFailed, "query_failed"),
484            (WireErrorCode::DeployDenied, "deploy_denied"),
485            (WireErrorCode::VersionPinned, "version_pinned"),
486            (WireErrorCode::NotOwner, "not_owner"),
487            (WireErrorCode::InvalidState, "invalid_state"),
488        ];
489        assert_eq!(
490            expected.len(),
491            all_codes().len(),
492            "every WireErrorCode variant must have a pinned string code"
493        );
494        for &(code, string) in expected {
495            assert_eq!(code.as_str(), string, "{code:?} must keep code {string}");
496        }
497    }
498
499    #[test]
500    fn json_codes_match_as_str_and_round_trip() -> Result<(), serde_json::Error> {
501        for code in all_codes() {
502            let serialized = serde_json::to_value(code)?;
503            assert_eq!(
504                serialized,
505                serde_json::Value::String(code.as_str().to_owned()),
506                "JSON serialization of {code:?} must equal as_str()",
507            );
508            let deserialized: WireErrorCode =
509                serde_json::from_value(serde_json::Value::String(code.as_str().to_owned()))?;
510            assert_eq!(deserialized, code, "{code:?} must round-trip through JSON");
511
512            let error = WireError::new(code, format!("message for {}", code.as_str()));
513            let body = serde_json::to_value(&error)?;
514            assert_eq!(
515                body.get("code"),
516                Some(&serde_json::Value::String(code.as_str().to_owned())),
517                "WireError JSON body must carry the snake_case code for {code:?}",
518            );
519            let decoded: WireError = serde_json::from_value(body)?;
520            assert_eq!(decoded, error);
521        }
522        Ok(())
523    }
524
525    #[test]
526    fn proto_round_trips_every_code() -> Result<(), WireError> {
527        for code in all_codes() {
528            let error = WireError::new_with_type(
529                code,
530                format!("{}Variant", code.as_str()),
531                format!("message for {}", code.as_str()),
532            );
533            let proto = ProtoWireError::from(error.clone());
534            let decoded = WireError::try_from(proto)?;
535            assert_eq!(decoded, error);
536        }
537
538        Ok(())
539    }
540
541    #[test]
542    fn rejects_unspecified_proto_code() {
543        let proto = ProtoWireError {
544            code: 0,
545            message: String::from("missing"),
546            error_type: None,
547        };
548
549        let result = WireError::try_from(proto);
550        assert_eq!(
551            result,
552            Err(WireError::backend("wire error code is missing"))
553        );
554    }
555
556    #[test]
557    fn representative_documented_mappings_use_stable_codes() {
558        let engine_unknown_workflow = WireError::not_found("workflow was not found");
559        let store_sequence_conflict = WireError::sequence_conflict("event sequence conflicted");
560
561        assert_eq!(engine_unknown_workflow.code, WireErrorCode::NotFound);
562        assert_eq!(
563            store_sequence_conflict.code,
564            WireErrorCode::SequenceConflict
565        );
566        assert_eq!(
567            WireError::namespace_denied("denied").code,
568            WireErrorCode::NamespaceDenied
569        );
570        assert_eq!(
571            WireError::query_timeout("timeout").code,
572            WireErrorCode::QueryTimeout
573        );
574        assert_eq!(
575            WireError::unknown_query("unknown").code,
576            WireErrorCode::UnknownQuery
577        );
578        assert_eq!(
579            WireError::not_running("terminal").code,
580            WireErrorCode::NotRunning
581        );
582        assert_eq!(
583            WireError::invalid_input("malformed").code,
584            WireErrorCode::InvalidInput
585        );
586        assert_eq!(
587            WireError::query_failed("handler raised").code,
588            WireErrorCode::QueryFailed
589        );
590        assert_eq!(
591            WireError::deploy_denied("no deploy grant").code,
592            WireErrorCode::DeployDenied
593        );
594        assert_eq!(
595            WireError::version_pinned("pinned by live run").code,
596            WireErrorCode::VersionPinned
597        );
598        assert_eq!(
599            WireError::not_owner("wrong shard owner").code,
600            WireErrorCode::NotOwner
601        );
602        assert_eq!(
603            WireError::invalid_state("run is not reopenable").code,
604            WireErrorCode::InvalidState
605        );
606    }
607}