Skip to main content

actionqueue_executor_local/
retry.rs

1//! Retry transition helper for attempt outcomes.
2//!
3//! This module deterministically maps a completed attempt outcome to the next
4//! run-state intent while enforcing the `max_attempts` hard cap.
5
6use std::error::Error;
7use std::fmt;
8
9use actionqueue_core::run::state::RunState;
10
11use crate::attempt_runner::{AttemptOutcomeKind, RetryDecisionInput};
12
13/// Error returned when retry decision inputs violate retry invariants.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum RetryDecisionError {
16    /// `max_attempts` is invalid and cannot be used for cap enforcement.
17    InvalidMaxAttempts {
18        /// Invalid configured attempt cap.
19        max_attempts: u32,
20    },
21    /// `attempt_number` is invalid and cannot represent a completed attempt.
22    InvalidAttemptNumber {
23        /// Invalid attempt number.
24        attempt_number: u32,
25    },
26    /// A completed attempt number exceeded the configured hard cap.
27    ///
28    /// This explicitly prevents any `N + 1` retry path.
29    AttemptExceedsCap {
30        /// Completed attempt number.
31        attempt_number: u32,
32        /// Configured hard attempt cap.
33        max_attempts: u32,
34    },
35}
36
37impl fmt::Display for RetryDecisionError {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::InvalidMaxAttempts { max_attempts } => {
41                write!(f, "invalid max_attempts value ({max_attempts}); expected >= 1")
42            }
43            Self::InvalidAttemptNumber { attempt_number } => {
44                write!(f, "invalid attempt_number value ({attempt_number}); expected >= 1")
45            }
46            Self::AttemptExceedsCap { attempt_number, max_attempts } => {
47                write!(f, "attempt_number ({attempt_number}) exceeds max_attempts ({max_attempts})",)
48            }
49        }
50    }
51}
52
53impl Error for RetryDecisionError {}
54
55/// Next run-state intent derived from one completed attempt.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[must_use]
58pub enum RetryDecision {
59    /// Transition to `Completed`.
60    Complete,
61    /// Transition to `RetryWait`.
62    Retry,
63    /// Transition to `Failed`.
64    Fail,
65    /// Transition to `Suspended`. Does not count toward the max_attempts cap.
66    Suspend,
67}
68
69impl RetryDecision {
70    /// Returns the canonical target run state for this decision.
71    pub fn target_state(self) -> RunState {
72        match self {
73            Self::Complete => RunState::Completed,
74            Self::Retry => RunState::RetryWait,
75            Self::Fail => RunState::Failed,
76            Self::Suspend => RunState::Suspended,
77        }
78    }
79}
80
81/// Computes retry transition intent from attempt outcome and counters.
82///
83/// # Invariants
84///
85/// - `max_attempts` must be at least 1.
86/// - `attempt_number` must be at least 1.
87/// - `attempt_number` must not exceed `max_attempts` (except for Suspended outcomes,
88///   which bypass cap validation and always return `Suspend`).
89/// - Retryable outcomes only produce `Retry` when `attempt_number < max_attempts`.
90pub fn decide_retry_transition(
91    input: &RetryDecisionInput,
92) -> Result<RetryDecision, RetryDecisionError> {
93    // Suspended bypasses cap validation. Suspended attempts do not count toward
94    // max_attempts. The dispatch loop tracks effective attempt count separately.
95    if input.outcome_kind == AttemptOutcomeKind::Suspended {
96        return Ok(RetryDecision::Suspend);
97    }
98
99    validate_retry_input(input)?;
100
101    let decision = match input.outcome_kind {
102        AttemptOutcomeKind::Success => RetryDecision::Complete,
103        AttemptOutcomeKind::TerminalFailure => RetryDecision::Fail,
104        AttemptOutcomeKind::RetryableFailure | AttemptOutcomeKind::Timeout => {
105            if input.attempt_number < input.max_attempts {
106                RetryDecision::Retry
107            } else {
108                RetryDecision::Fail
109            }
110        }
111        // Handled above — unreachable here but exhaustive match required.
112        AttemptOutcomeKind::Suspended => RetryDecision::Suspend,
113    };
114
115    Ok(decision)
116}
117
118/// Returns whether another attempt may be scheduled from this outcome.
119pub fn can_retry(input: &RetryDecisionInput) -> Result<bool, RetryDecisionError> {
120    Ok(matches!(decide_retry_transition(input)?, RetryDecision::Retry))
121}
122
123fn validate_retry_input(input: &RetryDecisionInput) -> Result<(), RetryDecisionError> {
124    if input.max_attempts == 0 {
125        return Err(RetryDecisionError::InvalidMaxAttempts { max_attempts: input.max_attempts });
126    }
127
128    if input.attempt_number == 0 {
129        return Err(RetryDecisionError::InvalidAttemptNumber {
130            attempt_number: input.attempt_number,
131        });
132    }
133
134    if input.attempt_number > input.max_attempts {
135        return Err(RetryDecisionError::AttemptExceedsCap {
136            attempt_number: input.attempt_number,
137            max_attempts: input.max_attempts,
138        });
139    }
140
141    Ok(())
142}
143
144#[cfg(test)]
145mod tests {
146    use actionqueue_core::ids::{AttemptId, RunId};
147    use actionqueue_core::run::state::RunState;
148
149    use super::{can_retry, decide_retry_transition, RetryDecision, RetryDecisionError};
150    use crate::attempt_runner::{AttemptOutcomeKind, RetryDecisionInput};
151
152    fn make_input(
153        outcome_kind: AttemptOutcomeKind,
154        attempt_number: u32,
155        max_attempts: u32,
156    ) -> RetryDecisionInput {
157        RetryDecisionInput {
158            run_id: RunId::new(),
159            attempt_id: AttemptId::new(),
160            attempt_number,
161            max_attempts,
162            outcome_kind,
163        }
164    }
165
166    #[test]
167    fn success_always_completes() {
168        let input = make_input(AttemptOutcomeKind::Success, 1, 3);
169        let decision = decide_retry_transition(&input).expect("decision should succeed");
170
171        assert_eq!(decision, RetryDecision::Complete);
172        assert_eq!(decision.target_state(), RunState::Completed);
173        assert!(!can_retry(&input).expect("validation should pass"));
174    }
175
176    #[test]
177    fn terminal_failure_always_fails() {
178        let input = make_input(AttemptOutcomeKind::TerminalFailure, 2, 5);
179        let decision = decide_retry_transition(&input).expect("decision should succeed");
180
181        assert_eq!(decision, RetryDecision::Fail);
182        assert_eq!(decision.target_state(), RunState::Failed);
183        assert!(!can_retry(&input).expect("validation should pass"));
184    }
185
186    #[test]
187    fn retryable_failure_retries_under_cap() {
188        let input = make_input(AttemptOutcomeKind::RetryableFailure, 2, 3);
189        let decision = decide_retry_transition(&input).expect("decision should succeed");
190
191        assert_eq!(decision, RetryDecision::Retry);
192        assert_eq!(decision.target_state(), RunState::RetryWait);
193        assert!(can_retry(&input).expect("validation should pass"));
194    }
195
196    #[test]
197    fn retryable_failure_fails_at_cap_without_n_plus_one() {
198        let input = make_input(AttemptOutcomeKind::RetryableFailure, 3, 3);
199        let decision = decide_retry_transition(&input).expect("decision should succeed");
200
201        assert_eq!(decision, RetryDecision::Fail);
202        assert!(!can_retry(&input).expect("validation should pass"));
203    }
204
205    #[test]
206    fn timeout_retries_under_cap_and_fails_at_cap() {
207        let under_cap = make_input(AttemptOutcomeKind::Timeout, 1, 2);
208        let at_cap = make_input(AttemptOutcomeKind::Timeout, 2, 2);
209
210        assert_eq!(
211            decide_retry_transition(&under_cap).expect("decision should succeed"),
212            RetryDecision::Retry
213        );
214        assert_eq!(
215            decide_retry_transition(&at_cap).expect("decision should succeed"),
216            RetryDecision::Fail
217        );
218    }
219
220    #[test]
221    fn invalid_max_attempts_is_rejected() {
222        let input = make_input(AttemptOutcomeKind::RetryableFailure, 1, 0);
223
224        assert_eq!(
225            decide_retry_transition(&input),
226            Err(RetryDecisionError::InvalidMaxAttempts { max_attempts: 0 })
227        );
228    }
229
230    #[test]
231    fn invalid_attempt_number_is_rejected() {
232        let input = make_input(AttemptOutcomeKind::RetryableFailure, 0, 1);
233
234        assert_eq!(
235            decide_retry_transition(&input),
236            Err(RetryDecisionError::InvalidAttemptNumber { attempt_number: 0 })
237        );
238    }
239
240    #[test]
241    fn n_plus_one_attempt_path_is_rejected() {
242        let input = make_input(AttemptOutcomeKind::RetryableFailure, 4, 3);
243
244        assert_eq!(
245            decide_retry_transition(&input),
246            Err(RetryDecisionError::AttemptExceedsCap { attempt_number: 4, max_attempts: 3 })
247        );
248    }
249
250    #[test]
251    fn suspended_always_suspends() {
252        let input = make_input(AttemptOutcomeKind::Suspended, 1, 3);
253        let decision = decide_retry_transition(&input).expect("decision should succeed");
254
255        assert_eq!(decision, RetryDecision::Suspend);
256        assert_eq!(decision.target_state(), RunState::Suspended);
257        assert!(!can_retry(&input).expect("validation should pass"));
258    }
259
260    #[test]
261    fn suspended_bypasses_cap_validation() {
262        // attempt_number > max_attempts would normally be rejected, but
263        // Suspended bypasses cap validation entirely.
264        let input = make_input(AttemptOutcomeKind::Suspended, 5, 3);
265        let decision = decide_retry_transition(&input).expect("decision should succeed");
266
267        assert_eq!(decision, RetryDecision::Suspend);
268    }
269}