use super::{Retrying, Waiter};
use crate::domain::action::identity::{ActionId, CommitDate, CommitSha, Repository, Version};
use crate::domain::action::resolved::Commit;
use crate::domain::action::spec::Spec as ActionSpec;
use crate::domain::action::specifier::Specifier;
use crate::domain::resolution::{
Error as ResolutionError, Forge, MAX_RETRY_WAIT_SECS, RetryAfter, ShaDescription,
VersionRegistry,
};
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Event {
Announced(String),
Waited(Duration),
}
#[derive(Default)]
struct Timeline {
events: RefCell<Vec<Event>>,
}
impl Timeline {
fn waits(&self) -> Vec<Duration> {
self.events
.borrow()
.iter()
.filter_map(|event| match event {
&Event::Waited(duration) => Some(duration),
Event::Announced(_) => None,
})
.collect()
}
fn announcements(&self) -> Vec<String> {
self.events
.borrow()
.iter()
.filter_map(|event| match event {
Event::Announced(message) => Some(message.clone()),
Event::Waited(_) => None,
})
.collect()
}
fn announce(&self, message: &str) {
self.events
.borrow_mut()
.push(Event::Announced(message.to_owned()));
}
}
impl Waiter for &Timeline {
fn wait(&self, duration: Duration) {
self.events.borrow_mut().push(Event::Waited(duration));
}
}
struct ScriptedRegistry {
script: Vec<Result<Commit, ResolutionError>>,
calls: RefCell<usize>,
}
impl ScriptedRegistry {
fn new(script: Vec<Result<Commit, ResolutionError>>) -> Self {
Self {
script,
calls: RefCell::new(0),
}
}
fn calls(&self) -> usize {
*self.calls.borrow()
}
fn next(&self) -> Result<Commit, ResolutionError> {
let index = *self.calls.borrow();
*self.calls.borrow_mut() += 1;
let last = self.script.len().saturating_sub(1);
self.script[index.min(last)].clone()
}
}
impl VersionRegistry for ScriptedRegistry {
fn lookup_sha(&self, _id: &ActionId, _version: &Version) -> Result<Commit, ResolutionError> {
self.next()
}
fn all_tags(&self, _id: &ActionId) -> Result<Vec<Version>, ResolutionError> {
self.next().map(|_| Vec::new())
}
fn describe_sha(
&self,
_id: &ActionId,
_sha: &CommitSha,
) -> Result<ShaDescription, ResolutionError> {
let commit = self.next()?;
Ok(ShaDescription {
tags: Vec::new(),
repository: commit.repository,
date: commit.date,
})
}
}
fn commit() -> Commit {
Commit {
sha: CommitSha::from("abc123def456789012345678901234567890abcd"),
repository: Repository::from("actions/checkout"),
ref_type: None,
date: CommitDate::from("2026-01-01T00:00:00Z"),
}
}
fn rate_limited(retry_after: RetryAfter) -> ResolutionError {
ResolutionError::RateLimited {
forge: Forge::GitHub,
retry_after,
}
}
fn id() -> ActionId {
ActionId::from("actions/checkout")
}
fn version() -> Version {
Version::from("v4")
}
fn retrying(
script: Vec<Result<Commit, ResolutionError>>,
timeline: &Timeline,
) -> Retrying<'_, ScriptedRegistry, &Timeline> {
Retrying::new(ScriptedRegistry::new(script)).with_waiter(timeline)
}
fn backoff_schedule() -> Vec<Duration> {
vec![Duration::from_secs(1), Duration::from_secs(2)]
}
#[test]
fn transient_rate_limit_resolves_without_user_intervention() {
let timeline = Timeline::default();
let registry = retrying(
vec![Err(rate_limited(RetryAfter::Unstated)), Ok(commit())],
&timeline,
);
let result = registry.lookup_sha(&id(), &version());
assert!(result.is_ok(), "the retry recovers the transient limit");
assert_eq!(
registry.inner.calls(),
2,
"one failed attempt plus one successful retry"
);
}
#[test]
fn persistent_rate_limit_fails_after_bounded_attempts() {
let timeline = Timeline::default();
let registry = retrying(vec![Err(rate_limited(RetryAfter::Unstated))], &timeline);
let result = registry.lookup_sha(&id(), &version());
assert!(result.is_err(), "an exhausted quota still fails");
assert_eq!(
registry.inner.calls(),
3,
"bounded by the BACKOFF schedule, and greater than 1 so retry actually happened"
);
}
#[test]
fn a_non_retryable_failure_is_returned_on_the_first_attempt() {
let cases = [
(
ResolutionError::AuthRequired {
forge: Forge::GitHub,
},
"reissuing the same absent credential cannot succeed",
),
(
ResolutionError::ResolveFailed {
spec: ActionSpec::new(id(), Specifier::from_v1("v4")),
reason: "not found".to_owned(),
},
"a 404 will not become a 200",
),
];
for (error, why) in cases {
let timeline = Timeline::default();
let registry = retrying(vec![Err(error)], &timeline);
registry.lookup_sha(&id(), &version()).unwrap_err();
assert_eq!(registry.inner.calls(), 1, "{why}");
assert!(timeline.waits().is_empty(), "and never sleeps: {why}");
}
}
#[test]
fn every_trait_method_retries_not_just_lookup_sha() {
let timeline = Timeline::default();
let tags = retrying(
vec![Err(rate_limited(RetryAfter::Unstated)), Ok(commit())],
&timeline,
);
tags.all_tags(&id()).unwrap();
assert_eq!(tags.inner.calls(), 2, "all_tags routes through the loop");
let describe = retrying(
vec![Err(rate_limited(RetryAfter::Unstated)), Ok(commit())],
&timeline,
);
let sha = CommitSha::from("abc123def456789012345678901234567890abcd");
describe.describe_sha(&id(), &sha).unwrap();
assert_eq!(
describe.inner.calls(),
2,
"describe_sha routes through the loop"
);
}
#[test]
fn a_stated_reset_above_the_backoff_step_is_waited_out() {
let timeline = Timeline::default();
let registry = retrying(
vec![
Err(rate_limited(RetryAfter::After(Duration::from_secs(3)))),
Ok(commit()),
],
&timeline,
);
registry.lookup_sha(&id(), &version()).unwrap();
assert_eq!(
timeline.waits(),
vec![Duration::from_secs(3)],
"a stated reset above the backoff step wins over the schedule"
);
}
#[test]
fn a_stated_reset_is_floored_to_the_backoff_and_clamped_to_the_cap() {
let cap = Duration::from_secs(MAX_RETRY_WAIT_SECS);
let cases = [
(
Duration::ZERO,
backoff_schedule(),
"a 0s reset — the forge's whole-second epoch rounding down, or a local \
clock running ahead — is floored to the backoff, never hammering the forge",
),
(
Duration::from_secs(1),
backoff_schedule(),
"a reset restated every attempt escalates instead of re-waiting 1s",
),
(
Duration::from_hours(1),
vec![cap, cap],
"an hour-long stated reset never becomes an hour-long sleep; only a \
second forge can state this, since the GitHub registry caps first",
),
];
for (stated, expected, why) in cases {
let timeline = Timeline::default();
let registry = retrying(
vec![Err(rate_limited(RetryAfter::After(stated)))],
&timeline,
);
registry.lookup_sha(&id(), &version()).unwrap_err();
assert_eq!(timeline.waits(), expected, "{why}");
}
}
#[test]
fn an_unstated_reset_falls_back_to_increasing_backoff() {
let timeline = Timeline::default();
let registry = retrying(vec![Err(rate_limited(RetryAfter::Unstated))], &timeline);
registry.lookup_sha(&id(), &version()).unwrap_err();
assert_eq!(
timeline.waits(),
backoff_schedule(),
"backoff increases between attempts"
);
}
#[test]
fn a_distant_reset_stops_rather_than_backing_off() {
let timeline = Timeline::default();
let registry = retrying(vec![Err(rate_limited(RetryAfter::TooDistant))], &timeline);
let result = registry.lookup_sha(&id(), &version());
assert!(result.is_err(), "the user gets their warning now");
assert_eq!(registry.inner.calls(), 1, "no retry on an hour-out reset");
assert!(
timeline.waits().is_empty(),
"and above all, no stalled terminal"
);
}
#[test]
fn each_wait_is_announced_before_it_happens() {
let timeline = Rc::new(Timeline::default());
let sink = Rc::clone(&timeline);
let registry = Retrying::new(ScriptedRegistry::new(vec![
Err(rate_limited(RetryAfter::After(Duration::from_secs(2)))),
Ok(commit()),
]))
.with_notifier(move |message| sink.announce(message))
.with_waiter(&*timeline);
registry.lookup_sha(&id(), &version()).unwrap();
let announcements = timeline.announcements();
assert_eq!(announcements.len(), 1, "one notice per wait");
let notice = announcements.first().unwrap();
assert!(notice.contains("rate limit"), "names the cause: {notice}");
assert!(notice.contains('2'), "names the duration: {notice}");
assert_eq!(
*timeline.events.borrow(),
vec![
Event::Announced(notice.clone()),
Event::Waited(Duration::from_secs(2))
],
"the explanation lands before the pause, never after it"
);
}