Skip to main content

aion_core/
error.rs

1//! Error types shared by workflow and activity histories.
2
3use serde::{Deserialize, Serialize};
4
5use crate::Payload;
6
7/// Classification for an activity failure.
8#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
9pub enum ActivityErrorKind {
10    /// The activity failure may be retried according to the activity's retry policy.
11    Retryable,
12    /// The provider declined the activity under policy; the engine must route it
13    /// before considering any configured same-provider retry.
14    PolicyRefused,
15    /// The activity failure is terminal and must not be retried.
16    Terminal,
17}
18
19/// Failure reported by an activity execution.
20///
21/// The engine consults [`ActivityError::is_retryable`] to decide whether to
22/// apply the activity's retry policy or fail the workflow.
23#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq, thiserror::Error)]
24#[error("{message}")]
25pub struct ActivityError {
26    /// Explicit retryability classification for this activity failure.
27    pub kind: ActivityErrorKind,
28    /// Human-readable error message.
29    pub message: String,
30    /// Optional structured details carried as an opaque payload.
31    pub details: Option<Payload>,
32}
33
34impl ActivityError {
35    /// Returns whether the engine may retry this activity failure.
36    ///
37    /// This is not the complement of [`Self::is_settled`]: a policy refusal is
38    /// neither an ordinary retryable failure nor a settling terminal failure.
39    #[must_use]
40    pub fn is_retryable(&self) -> bool {
41        matches!(self.kind, ActivityErrorKind::Retryable)
42    }
43
44    /// Whether this failure settles its activity: only `Terminal` does.
45    ///
46    /// The complement of [`Self::is_retryable`] is not this question.
47    /// `PolicyRefused` is neither retryable by the ordinary budget nor settling,
48    /// because routing may still hop to a declared fallback queue or spend a
49    /// configured same-provider retry. The final disposition is recorded as
50    /// `Terminal`, so settlement code asks this predicate directly.
51    #[must_use]
52    pub fn is_settled(&self) -> bool {
53        matches!(self.kind, ActivityErrorKind::Terminal)
54    }
55}
56
57/// Terminal failure reported by a workflow execution.
58#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq, thiserror::Error)]
59#[error("{message}")]
60pub struct WorkflowError {
61    /// Human-readable error message.
62    pub message: String,
63    /// Optional structured details carried as an opaque payload.
64    pub details: Option<Payload>,
65}
66
67impl From<ActivityError> for WorkflowError {
68    fn from(error: ActivityError) -> Self {
69        Self {
70            message: error.message,
71            details: error.details,
72        }
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use serde_json::json;
79
80    use super::{ActivityError, ActivityErrorKind, WorkflowError};
81    use crate::Payload;
82
83    #[test]
84    fn activity_error_reports_retryable_classification() {
85        let error = ActivityError {
86            kind: ActivityErrorKind::Retryable,
87            message: String::from("temporary outage"),
88            details: None,
89        };
90
91        assert!(error.is_retryable());
92    }
93
94    #[test]
95    fn activity_error_reports_terminal_classification() {
96        let error = ActivityError {
97            kind: ActivityErrorKind::Terminal,
98            message: String::from("invalid request"),
99            details: None,
100        };
101
102        assert!(!error.is_retryable());
103        assert!(error.is_settled());
104    }
105
106    #[test]
107    fn policy_refused_is_neither_retryable_nor_settled() {
108        let error = ActivityError {
109            kind: ActivityErrorKind::PolicyRefused,
110            message: String::from("provider policy"),
111            details: None,
112        };
113
114        assert!(!error.is_retryable());
115        assert!(!error.is_settled());
116    }
117
118    #[test]
119    fn errors_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
120        let activity_error = ActivityError {
121            kind: ActivityErrorKind::Retryable,
122            message: String::from("connection reset"),
123            details: Some(Payload::from_json(&json!({"retry_after_ms": 500}))?),
124        };
125        let json = serde_json::to_string(&activity_error)?;
126        let decoded: ActivityError = serde_json::from_str(&json)?;
127        assert_eq!(activity_error, decoded);
128
129        let workflow_error = WorkflowError {
130            message: String::from("workflow failed"),
131            details: None,
132        };
133        let json = serde_json::to_string(&workflow_error)?;
134        let decoded: WorkflowError = serde_json::from_str(&json)?;
135        assert_eq!(workflow_error, decoded);
136
137        Ok(())
138    }
139
140    #[test]
141    fn workflow_error_from_activity_error_preserves_message_and_details()
142    -> Result<(), Box<dyn std::error::Error>> {
143        let details = Payload::from_json(&json!({"code": "rate_limited", "after_ms": 1000}))?;
144        let activity_error = ActivityError {
145            kind: ActivityErrorKind::Terminal,
146            message: String::from("activity failed permanently"),
147            details: Some(details.clone()),
148        };
149
150        let workflow_error = WorkflowError::from(activity_error);
151
152        assert_eq!(workflow_error.message, "activity failed permanently");
153        assert_eq!(workflow_error.details, Some(details));
154        Ok(())
155    }
156}