actionqueue_executor_local/
retry.rs1use std::error::Error;
7use std::fmt;
8
9use actionqueue_core::run::state::RunState;
10
11use crate::attempt_runner::{AttemptOutcomeKind, RetryDecisionInput};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum RetryDecisionError {
16 InvalidMaxAttempts {
18 max_attempts: u32,
20 },
21 InvalidAttemptNumber {
23 attempt_number: u32,
25 },
26 AttemptExceedsCap {
30 attempt_number: u32,
32 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[must_use]
58pub enum RetryDecision {
59 Complete,
61 Retry,
63 Fail,
65 Suspend,
67}
68
69impl RetryDecision {
70 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
81pub fn decide_retry_transition(
91 input: &RetryDecisionInput,
92) -> Result<RetryDecision, RetryDecisionError> {
93 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 AttemptOutcomeKind::Suspended => RetryDecision::Suspend,
113 };
114
115 Ok(decision)
116}
117
118pub 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 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}