Skip to content

Commit

Permalink
feat: add retry functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
c-git committed Jan 14, 2025
1 parent ae66515 commit 75d5d8f
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 13 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ anyhow = "1.0.95"
document-features = "0.2.10"
egui = { version = "0.30.0", default-features = false, optional = true }
futures = "0.3.28"
rand = "0.8.5"
reqwest = { version = "0.12.12", default-features = false }
thiserror = "2.0.9"
tracing = "0.1.41"
Expand Down
189 changes: 176 additions & 13 deletions src/data_state_retry.rs
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
use crate::{DataState, ErrorBounds};
use tracing::{error, warn};

use crate::{data_state::CanMakeProgress, Awaiting, DataState, ErrorBounds};
use std::fmt::Debug;
use std::ops::Range;
use std::time::Instant;
use std::time::{Duration, Instant};

/// Automatically retries with a delay on failure until attempts are exhausted
#[derive(Debug)]
pub struct DataStateRetry<T, E: ErrorBounds = anyhow::Error> {
/// The wrapped [`DataState`]
pub inner: DataState<T, E>,
/// Number of attempts that the retries get reset to
pub max_attempts: u8,
/// The range of milliseconds to select a random value from to set the delay
/// to retry
pub retry_delay_ms: Range<u16>,
pub retry_delay_millis: Range<u16>,
attempts_left: u8,
last_attempt: Option<Instant>,
inner: DataState<T, E>, // Not public to ensure resets happen as they should
next_allowed_attempt: Instant,
}

impl<T, E: ErrorBounds> DataStateRetry<T, E> {
/// Creates a new instance of [DataStateRetry]
pub fn new(max_attempts: u8, retry_delay_ms: Range<u16>) -> Self {
pub fn new(max_attempts: u8, retry_delay_millis: Range<u16>) -> Self {
Self {
max_attempts,
retry_delay_ms,
retry_delay_millis,
..Default::default()
}
}
Expand All @@ -31,9 +33,170 @@ impl<T, E: ErrorBounds> DataStateRetry<T, E> {
self.attempts_left
}

/// If an attempt was made the instant that it happened at
pub fn last_attempt(&self) -> Option<Instant> {
self.last_attempt
/// The instant that needs to be waited for before another attempt is
/// allowed
pub fn next_allowed_attempt(&self) -> Instant {
self.next_allowed_attempt
}

/// Provides access to the inner [`DataState`]
pub fn inner(&self) -> &DataState<T, E> {
&self.inner
}

/// Consumes self and returns the unwrapped inner
pub fn into_inner(self) -> DataState<T, E> {
self.inner
}

/// Provides access to the stored data if available (returns Some if
/// self.inner is `Data::Present(_)`)
pub fn present(&self) -> Option<&T> {
if let DataState::Present(data) = self.inner.as_ref() {
Some(data)
} else {
None
}
}

/// Provides mutable access to the stored data if available (returns Some if
/// self.inner is `Data::Present(_)`)
pub fn present_mut(&mut self) -> Option<&mut T> {
if let DataState::Present(data) = self.inner.as_mut() {
Some(data)
} else {
None
}
}

#[cfg(feature = "egui")]
/// Attempts to load the data and displays appropriate UI if applicable.
///
/// Note see [`DataState::egui_get`] for more info.
#[must_use]
pub fn egui_get<F>(
&mut self,
ui: &mut egui::Ui,
retry_msg: Option<&str>,
fetch_fn: F,
) -> CanMakeProgress
where
F: FnOnce() -> Awaiting<T, E>,
{
match self.inner.as_ref() {
DataState::None | DataState::AwaitingResponse(_) => {
self.ui_spinner_with_attempt_count(ui);
self.get(fetch_fn)
}
DataState::Present(_data) => {
// Does nothing as data is already present
CanMakeProgress::UnableToMakeProgress
}
DataState::Failed(e) => {
ui.colored_label(
ui.visuals().error_fg_color,
format!("{} attempts exhausted. {e}", self.max_attempts),
);
if ui.button(retry_msg.unwrap_or("Restart Requests")).clicked() {
self.reset_attempts();
self.inner = DataState::default();
}
CanMakeProgress::AbleToMakeProgress
}
}
}

/// Attempts to load the data and returns if it is able to make progress.
///
/// See [`DataState::get`] for more info.
#[must_use]
pub fn get<F>(&mut self, fetch_fn: F) -> CanMakeProgress
where
F: FnOnce() -> Awaiting<T, E>,
{
match self.inner.as_mut() {
DataState::None => {
// Going to make an attempt, set when the next attempt is allowed
use rand::Rng;
let wait_time_in_millis = rand::thread_rng()
.gen_range(self.retry_delay_millis.clone())
.into();
self.next_allowed_attempt = Instant::now()
.checked_add(Duration::from_millis(wait_time_in_millis))
.expect("failed to get random delay, value was out of range");

self.inner.get(fetch_fn)
}
DataState::AwaitingResponse(rx) => {
if let Some(new_state) = DataState::await_data(rx) {
// TODO 4: Add some tests to ensure await_data work as this function assumes
self.inner = match new_state.as_ref() {
DataState::None => {
error!("Unexpected new state received of DataState::None");
unreachable!("Only expect Failed or Present variants to be returned but got None")
}
DataState::AwaitingResponse(_) => {
error!("Unexpected new state received of AwaitingResponse");
unreachable!("Only expect Failed or Present variants to be returned bug got AwaitingResponse")
}
DataState::Present(_) => {
// Data was successfully received
self.reset_attempts();
new_state
}
DataState::Failed(_) => new_state,
};
}
CanMakeProgress::AbleToMakeProgress
}
DataState::Present(_) => self.inner.get(fetch_fn),
DataState::Failed(err_msg) => {
if self.attempts_left == 0 {
self.inner.get(fetch_fn)
} else {
let wait_duration_left = self
.next_allowed_attempt
.saturating_duration_since(Instant::now());
if wait_duration_left.is_zero() {
warn!(?err_msg, ?self.attempts_left, "retrying request");
self.attempts_left -= 1;
self.inner = DataState::None;
}
CanMakeProgress::AbleToMakeProgress
}
}
}
}

/// Resets the attempts taken
pub fn reset_attempts(&mut self) {
self.attempts_left = self.max_attempts;
self.next_allowed_attempt = Instant::now();
}

/// Clear stored data
pub fn clear(&mut self) {
self.inner = DataState::default();
}

/// Returns `true` if the internal data state is [`DataState::Present`].
#[must_use]
pub fn is_present(&self) -> bool {
self.inner.is_present()
}

/// Returns `true` if the internal data state is [`DataState::None`].
#[must_use]
pub fn is_none(&self) -> bool {
self.inner.is_none()
}

fn ui_spinner_with_attempt_count(&self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.spinner();
ui.separator();
ui.label(format!("{} attempts left", self.attempts_left))
});
}
}

Expand All @@ -42,9 +205,9 @@ impl<T, E: ErrorBounds> Default for DataStateRetry<T, E> {
Self {
inner: Default::default(),
max_attempts: 3,
retry_delay_ms: 1000..5000,
retry_delay_millis: 1000..5000,
attempts_left: 3,
last_attempt: Default::default(),
next_allowed_attempt: Instant::now(),
}
}
}
Expand Down

0 comments on commit 75d5d8f

Please sign in to comment.