Skip to main content

fakecloud_stepfunctions/
error_handling.rs

1use serde_json::Value;
2
3/// Reserved error names that a wildcard `ErrorEquals` never matches. AWS
4/// documents that `States.Runtime` and `States.DataLimitExceeded` always fail
5/// the execution and cannot be caught or retried by a `States.ALL` (or
6/// `States.TaskFailed`) wildcard.
7const NON_WILDCARD_ERRORS: [&str; 2] = ["States.Runtime", "States.DataLimitExceeded"];
8
9/// Decide whether a single `ErrorEquals` pattern matches an actual error name.
10///
11/// * `States.ALL` matches any error except the non-catchable reserved names.
12/// * `States.TaskFailed` acts as a wildcard for task-originated errors: it
13///   matches any of them except `States.Timeout` (and the non-catchable
14///   reserved names). It applies only when the error came from a Task state, so
15///   e.g. a Parallel/Map branch's `States.Runtime` is not swallowed by it.
16/// * Any other pattern is an exact error-name match.
17fn pattern_matches(pattern: &str, error: &str, is_task_error: bool) -> bool {
18    match pattern {
19        "States.ALL" => !NON_WILDCARD_ERRORS.contains(&error),
20        "States.TaskFailed" => {
21            is_task_error && error != "States.Timeout" && !NON_WILDCARD_ERRORS.contains(&error)
22        }
23        exact => exact == error,
24    }
25}
26
27/// Check if an error matches a Catch block and return the target state name.
28///
29/// `is_task_error` indicates the error originated from a Task state, which
30/// governs whether the `States.TaskFailed` wildcard applies.
31pub fn find_catcher(
32    catchers: &[Value],
33    error: &str,
34    is_task_error: bool,
35) -> Option<(String, Option<String>)> {
36    for catcher in catchers {
37        let error_equals = match catcher["ErrorEquals"].as_array() {
38            Some(arr) => arr,
39            None => continue,
40        };
41
42        let matches = error_equals
43            .iter()
44            .any(|e| pattern_matches(e.as_str().unwrap_or(""), error, is_task_error));
45
46        if matches {
47            let next = match catcher["Next"].as_str() {
48                Some(s) => s.to_string(),
49                None => continue, // skip malformed catcher, try next one
50            };
51            // Distinguish between absent ResultPath (None) and JSON null ResultPath
52            let result_path = if catcher.get("ResultPath").is_some_and(|v| v.is_null()) {
53                Some("null".to_string())
54            } else {
55                catcher["ResultPath"].as_str().map(|s| s.to_string())
56            };
57            return Some((next, result_path));
58        }
59    }
60    None
61}
62
63/// Check if we should retry an error based on Retry configuration.
64/// Returns the delay in milliseconds if we should retry, or None if retries are exhausted.
65///
66/// `is_task_error` indicates the error originated from a Task state, which
67/// governs whether the `States.TaskFailed` wildcard applies.
68pub fn should_retry(
69    retriers: &[Value],
70    error: &str,
71    attempt: u32,
72    is_task_error: bool,
73) -> Option<u64> {
74    for retrier in retriers {
75        let error_equals = match retrier["ErrorEquals"].as_array() {
76            Some(arr) => arr,
77            None => continue,
78        };
79
80        let matches = error_equals
81            .iter()
82            .any(|e| pattern_matches(e.as_str().unwrap_or(""), error, is_task_error));
83
84        if matches {
85            let max_attempts = retrier["MaxAttempts"].as_u64().unwrap_or(3) as u32;
86            if attempt >= max_attempts {
87                return None;
88            }
89
90            let interval_seconds = retrier["IntervalSeconds"].as_f64().unwrap_or(1.0);
91            let backoff_rate = retrier["BackoffRate"].as_f64().unwrap_or(2.0);
92            let max_delay = retrier["MaxDelaySeconds"].as_f64().unwrap_or(60.0);
93
94            let delay = interval_seconds * backoff_rate.powi(attempt as i32);
95            let delay = delay.min(max_delay);
96
97            return Some((delay * 1000.0) as u64);
98        }
99    }
100    None
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use serde_json::json;
107
108    #[test]
109    fn test_find_catcher_exact_match() {
110        let catchers = vec![json!({
111            "ErrorEquals": ["CustomError"],
112            "Next": "HandleError"
113        })];
114        let result = find_catcher(&catchers, "CustomError", true);
115        assert_eq!(result, Some(("HandleError".to_string(), None)));
116    }
117
118    #[test]
119    fn test_find_catcher_states_all() {
120        let catchers = vec![json!({
121            "ErrorEquals": ["States.ALL"],
122            "Next": "CatchAll"
123        })];
124        let result = find_catcher(&catchers, "AnyError", true);
125        assert_eq!(result, Some(("CatchAll".to_string(), None)));
126    }
127
128    #[test]
129    fn test_find_catcher_no_match() {
130        let catchers = vec![json!({
131            "ErrorEquals": ["SpecificError"],
132            "Next": "Handle"
133        })];
134        let result = find_catcher(&catchers, "DifferentError", true);
135        assert_eq!(result, None);
136    }
137
138    #[test]
139    fn test_find_catcher_with_result_path() {
140        let catchers = vec![json!({
141            "ErrorEquals": ["States.ALL"],
142            "Next": "Handle",
143            "ResultPath": "$.error"
144        })];
145        let result = find_catcher(&catchers, "AnyError", true);
146        assert_eq!(
147            result,
148            Some(("Handle".to_string(), Some("$.error".to_string())))
149        );
150    }
151
152    #[test]
153    fn test_find_catcher_skips_malformed_and_finds_next() {
154        // First catcher matches but has no Next field — should skip to second catcher
155        let catchers = vec![
156            json!({
157                "ErrorEquals": ["States.ALL"]
158                // missing "Next"
159            }),
160            json!({
161                "ErrorEquals": ["States.ALL"],
162                "Next": "FallbackHandler"
163            }),
164        ];
165        let result = find_catcher(&catchers, "AnyError", true);
166        assert_eq!(result, Some(("FallbackHandler".to_string(), None)));
167    }
168
169    // H1: States.TaskFailed is a wildcard for task-originated errors — a custom
170    // Lambda errorType surfaces as the Error name and must be caught.
171    #[test]
172    fn test_find_catcher_task_failed_wildcard_catches_custom_error() {
173        let catchers = vec![json!({
174            "ErrorEquals": ["States.TaskFailed"],
175            "Next": "Handler"
176        })];
177        let result = find_catcher(&catchers, "CustomLambdaError", true);
178        assert_eq!(result, Some(("Handler".to_string(), None)));
179    }
180
181    // H1: States.TaskFailed must NOT match States.Timeout (per AWS).
182    #[test]
183    fn test_find_catcher_task_failed_excludes_timeout() {
184        let catchers = vec![json!({
185            "ErrorEquals": ["States.TaskFailed"],
186            "Next": "Handler"
187        })];
188        assert_eq!(find_catcher(&catchers, "States.Timeout", true), None);
189    }
190
191    // H1: the TaskFailed wildcard only applies to task-originated errors.
192    #[test]
193    fn test_find_catcher_task_failed_only_for_task_errors() {
194        let catchers = vec![json!({
195            "ErrorEquals": ["States.TaskFailed"],
196            "Next": "Handler"
197        })];
198        assert_eq!(find_catcher(&catchers, "SomeBranchError", false), None);
199    }
200
201    // M2: States.ALL must NOT catch States.Runtime or States.DataLimitExceeded.
202    #[test]
203    fn test_find_catcher_states_all_excludes_runtime() {
204        let catchers = vec![json!({
205            "ErrorEquals": ["States.ALL"],
206            "Next": "Handler"
207        })];
208        assert_eq!(find_catcher(&catchers, "States.Runtime", true), None);
209        assert_eq!(
210            find_catcher(&catchers, "States.DataLimitExceeded", true),
211            None
212        );
213        // But States.ALL still catches States.Timeout and ordinary errors.
214        assert!(find_catcher(&catchers, "States.Timeout", true).is_some());
215        assert!(find_catcher(&catchers, "Whatever", true).is_some());
216    }
217
218    #[test]
219    fn test_should_retry_first_attempt() {
220        let retriers = vec![json!({
221            "ErrorEquals": ["States.ALL"],
222            "IntervalSeconds": 1,
223            "MaxAttempts": 3,
224            "BackoffRate": 2.0
225        })];
226        let result = should_retry(&retriers, "AnyError", 0, true);
227        assert_eq!(result, Some(1000)); // 1s * 2^0 = 1s
228    }
229
230    #[test]
231    fn test_should_retry_second_attempt() {
232        let retriers = vec![json!({
233            "ErrorEquals": ["States.ALL"],
234            "IntervalSeconds": 1,
235            "MaxAttempts": 3,
236            "BackoffRate": 2.0
237        })];
238        let result = should_retry(&retriers, "AnyError", 1, true);
239        assert_eq!(result, Some(2000)); // 1s * 2^1 = 2s
240    }
241
242    #[test]
243    fn test_should_retry_exhausted() {
244        let retriers = vec![json!({
245            "ErrorEquals": ["States.ALL"],
246            "MaxAttempts": 2
247        })];
248        let result = should_retry(&retriers, "AnyError", 2, true);
249        assert_eq!(result, None);
250    }
251
252    #[test]
253    fn test_should_retry_no_match() {
254        let retriers = vec![json!({
255            "ErrorEquals": ["SpecificError"],
256            "MaxAttempts": 3
257        })];
258        let result = should_retry(&retriers, "DifferentError", 0, true);
259        assert_eq!(result, None);
260    }
261
262    // H1: Retry on States.TaskFailed retries a custom task error.
263    #[test]
264    fn test_should_retry_task_failed_wildcard() {
265        let retriers = vec![json!({
266            "ErrorEquals": ["States.TaskFailed"],
267            "IntervalSeconds": 1,
268            "MaxAttempts": 3
269        })];
270        assert_eq!(should_retry(&retriers, "CustomError", 0, true), Some(1000));
271        // Not a task error → no retry.
272        assert_eq!(should_retry(&retriers, "CustomError", 0, false), None);
273    }
274
275    // M3: the computed backoff honours MaxDelaySeconds and is not clamped to 5s.
276    #[test]
277    fn test_should_retry_honours_max_delay_seconds() {
278        let retriers = vec![json!({
279            "ErrorEquals": ["States.ALL"],
280            "IntervalSeconds": 30,
281            "MaxAttempts": 5,
282            "BackoffRate": 2.0,
283            "MaxDelaySeconds": 40
284        })];
285        // 30 * 2^1 = 60s, clamped to MaxDelaySeconds (40s), NOT to 5s.
286        assert_eq!(should_retry(&retriers, "AnyError", 1, true), Some(40_000));
287        // First attempt: 30s, well above the old 5s cap.
288        assert_eq!(should_retry(&retriers, "AnyError", 0, true), Some(30_000));
289    }
290}