use super::*;
#[tokio::test]
async fn retryable_failure_redispatches_with_incremented_recorded_attempt() -> TestResult {
let harness = RetryLoopHarness::seeded(FIXED_RETRY_CONFIG).await?;
let dispatcher = ScriptedRetryDispatcher::new(vec![
Err("retryable:stream reset".to_owned()),
Ok(r#""done""#.to_owned()),
]);
let outcome = super::dispatch_with_retries(
&(Arc::clone(&dispatcher) as Arc<dyn ActivityDispatcher>),
&harness.seam,
&harness.request,
)
.await;
assert!(
matches!(
&outcome.terminal,
super::RetryLoopTerminal::Completed(payload) if payload == r#""done""#
),
"the second attempt's success must be the delivered outcome"
);
assert_eq!(outcome.attempt, 2, "the completing attempt is attempt 2");
assert_eq!(
dispatcher.seen_attempts(),
vec![1, 2],
"the wire must carry the incremented attempt on the re-dispatch"
);
let history = harness.history().await?;
assert!(
matches!(
history.get(3),
Some(Event::ActivityFailed { error, attempt: 1, .. })
if error.kind == aion_core::ActivityErrorKind::Retryable
&& error.message == "retryable:stream reset"
),
"the failed attempt must be recorded as a NON-terminal retryable failure: {history:#?}"
);
assert!(
matches!(
history.get(4),
Some(Event::ActivityStarted { attempt: 2, .. })
),
"the retry delivery must record its ActivityStarted: {history:#?}"
);
Ok(())
}
#[tokio::test]
async fn exhausted_retry_budget_names_exhaustion_and_last_refusal() -> TestResult {
let harness = RetryLoopHarness::seeded(FIXED_RETRY_CONFIG).await?;
let dispatcher = ScriptedRetryDispatcher::new(vec![
Err("retryable:reset one".to_owned()),
Err("retryable:reset two".to_owned()),
Err("retryable:reset three".to_owned()),
Ok(r#""never delivered""#.to_owned()),
]);
let outcome = super::dispatch_with_retries(
&(Arc::clone(&dispatcher) as Arc<dyn ActivityDispatcher>),
&harness.seam,
&harness.request,
)
.await;
assert!(
matches!(
&outcome.terminal,
super::RetryLoopTerminal::Failed(reason)
if reason == "exhausted: spent 3 of 3 attempts; last refusal: retryable:reset three"
),
"budget exhaustion must name itself, its spent budget, and the last refusal: {outcome:?}"
);
assert_eq!(outcome.attempt, 3, "the budget is total attempts");
assert_eq!(
dispatcher.seen_attempts(),
vec![1, 2, 3],
"exactly max_attempts deliveries, one per attempt"
);
let history = harness.history().await?;
let retryable_failures = history
.iter()
.filter(|event| {
matches!(
event,
Event::ActivityFailed { error, .. }
if error.kind == aion_core::ActivityErrorKind::Retryable
)
})
.count();
assert_eq!(retryable_failures, 2, "{history:#?}");
assert!(
matches!(
history.last(),
Some(Event::ActivityStarted { attempt: 3, .. })
),
"the final delivery's start must be recorded: {history:#?}"
);
Ok(())
}
#[tokio::test]
async fn non_retryable_failure_fails_immediately_without_a_retry_trail() -> TestResult {
let harness = RetryLoopHarness::seeded(FIXED_RETRY_CONFIG).await?;
let dispatcher = ScriptedRetryDispatcher::new(vec![Err("terminal:bad request".to_owned())]);
let outcome = super::dispatch_with_retries(
&(Arc::clone(&dispatcher) as Arc<dyn ActivityDispatcher>),
&harness.seam,
&harness.request,
)
.await;
assert!(matches!(
&outcome.terminal,
super::RetryLoopTerminal::Failed(reason) if reason == "terminal:bad request"
));
assert_eq!(outcome.attempt, 1);
assert_eq!(dispatcher.seen_attempts(), vec![1]);
assert_eq!(
harness.history().await?.len(),
3,
"no retry events may be recorded for a non-retryable failure"
);
Ok(())
}
#[tokio::test]
async fn absent_policy_keeps_run_exactly_once_for_retryable_failures() -> TestResult {
let harness = RetryLoopHarness::seeded(r#"{"retry":null}"#).await?;
let dispatcher = ScriptedRetryDispatcher::new(vec![Err("retryable:stream reset".to_owned())]);
let outcome = super::dispatch_with_retries(
&(Arc::clone(&dispatcher) as Arc<dyn ActivityDispatcher>),
&harness.seam,
&harness.request,
)
.await;
assert!(matches!(
&outcome.terminal,
super::RetryLoopTerminal::Failed(reason) if reason == "retryable:stream reset"
));
assert_eq!(dispatcher.seen_attempts(), vec![1]);
assert_eq!(harness.history().await?.len(), 3);
Ok(())
}
#[tokio::test]
async fn a_closed_engine_task_epoch_stops_the_retry_loop_writing() -> TestResult {
let harness = RetryLoopHarness::seeded(FIXED_RETRY_CONFIG).await?;
let before = harness.history().await?.len();
let control_dispatcher = ScriptedRetryDispatcher::new(vec![
Err("retryable:stream reset".to_owned()),
Ok(r#""done""#.to_owned()),
]);
let control = super::dispatch_with_retries(
&(Arc::clone(&control_dispatcher) as Arc<dyn ActivityDispatcher>),
&harness.seam,
&harness.request,
)
.await;
assert!(
matches!(control.terminal, super::RetryLoopTerminal::Completed(_)),
"control: an open epoch must let the loop finish normally"
);
let after_control = harness.history().await?.len();
assert!(
after_control > before,
"control: an open epoch must record the attempt trail ({before} -> {after_control})"
);
harness.seam.engine_tasks.begin_close();
assert!(
!harness.seam.engine_tasks.is_epoch_open(),
"the epoch must actually be closed, or what follows measures nothing"
);
let dispatcher = ScriptedRetryDispatcher::new(vec![
Err("retryable:stream reset".to_owned()),
Ok(r#""done""#.to_owned()),
]);
let outcome = super::dispatch_with_retries(
&(Arc::clone(&dispatcher) as Arc<dyn ActivityDispatcher>),
&harness.seam,
&harness.request,
)
.await;
assert!(
matches!(outcome.terminal, super::RetryLoopTerminal::SettledElsewhere),
"a closed epoch must stand the loop down rather than deliver an outcome for a run this \
process is no longer the writer for"
);
assert_eq!(
harness.history().await?.len(),
after_control,
"NOTHING may be appended after the epoch closes: an append here is the #119 \
second-writer breach — the survivor either takes a SequenceConflict or replays a \
command against an event the live run never issued"
);
Ok(())
}