use crate::domain::action::identity::{ActionId, CommitSha, Version};
use crate::domain::action::resolved::Commit;
use crate::domain::resolution::{
Error as ResolutionError, MAX_RETRY_WAIT_SECS, RetryAfter, ShaDescription, VersionRegistry,
};
use std::time::Duration;
const BACKOFF: [Duration; 2] = [Duration::from_secs(1), Duration::from_secs(2)];
const _: () = {
let [first, second] = BACKOFF;
assert!(
first.as_secs() <= MAX_RETRY_WAIT_SECS && second.as_secs() <= MAX_RETRY_WAIT_SECS,
"BACKOFF must stay within MAX_RETRY_WAIT_SECS"
);
};
pub trait Waiter {
fn wait(&self, duration: Duration);
}
#[derive(Debug, Default, Clone, Copy)]
pub struct ThreadWaiter;
impl Waiter for ThreadWaiter {
fn wait(&self, duration: Duration) {
std::thread::sleep(duration);
}
}
type Notifier<'notify> = Box<dyn Fn(&str) + 'notify>;
pub struct Retrying<'notify, R: VersionRegistry, W: Waiter = ThreadWaiter> {
inner: R,
waiter: W,
notifier: Option<Notifier<'notify>>,
}
impl<R: VersionRegistry> Retrying<'_, R> {
pub fn new(inner: R) -> Self {
Self {
inner,
waiter: ThreadWaiter,
notifier: None,
}
}
}
impl<'notify, R: VersionRegistry, W: Waiter> Retrying<'notify, R, W> {
#[must_use]
pub fn with_notifier<N: Fn(&str) + 'notify>(mut self, notifier: N) -> Self {
self.notifier = Some(Box::new(notifier));
self
}
#[cfg(test)]
fn with_waiter<W2: Waiter>(self, waiter: W2) -> Retrying<'notify, R, W2> {
Retrying {
inner: self.inner,
waiter,
notifier: self.notifier,
}
}
fn wait_for(error: &ResolutionError, retry_index: usize) -> Option<Duration> {
let ResolutionError::RateLimited { retry_after, .. } = error else {
return None;
};
let backoff = BACKOFF.get(retry_index).copied();
let cap = Duration::from_secs(MAX_RETRY_WAIT_SECS);
match retry_after {
RetryAfter::After(stated) => Some(backoff?.max(*stated).min(cap)),
RetryAfter::Unstated => backoff,
RetryAfter::TooDistant => None,
}
}
fn retrying<T>(
&self,
mut attempt: impl FnMut() -> Result<T, ResolutionError>,
) -> Result<T, ResolutionError> {
let mut retry_index: usize = 0;
loop {
let error = match attempt() {
Ok(value) => return Ok(value),
Err(error) => error,
};
if !error.is_retryable() {
return Err(error);
}
let Some(duration) = Self::wait_for(&error, retry_index) else {
return Err(error);
};
if let Some(notify) = self.notifier.as_ref() {
notify(&format!(
"{error}; retrying in {}s",
duration.as_secs_f32().ceil()
));
}
self.waiter.wait(duration);
retry_index = retry_index.saturating_add(1);
}
}
}
impl<R: VersionRegistry, W: Waiter> VersionRegistry for Retrying<'_, R, W> {
fn lookup_sha(&self, id: &ActionId, version: &Version) -> Result<Commit, ResolutionError> {
self.retrying(|| self.inner.lookup_sha(id, version))
}
fn all_tags(&self, id: &ActionId) -> Result<Vec<Version>, ResolutionError> {
self.retrying(|| self.inner.all_tags(id))
}
fn describe_sha(
&self,
id: &ActionId,
sha: &CommitSha,
) -> Result<ShaDescription, ResolutionError> {
self.retrying(|| self.inner.describe_sha(id, sha))
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::indexing_slicing,
clippy::arithmetic_side_effects,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
#[path = "retrying_tests.rs"]
mod tests;