Skip to content

Commit 75d5d8f

Browse files
committed
feat: add retry functionality
1 parent ae66515 commit 75d5d8f

File tree

3 files changed

+178
-13
lines changed

3 files changed

+178
-13
lines changed

Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ anyhow = "1.0.95"
2121
document-features = "0.2.10"
2222
egui = { version = "0.30.0", default-features = false, optional = true }
2323
futures = "0.3.28"
24+
rand = "0.8.5"
2425
reqwest = { version = "0.12.12", default-features = false }
2526
thiserror = "2.0.9"
2627
tracing = "0.1.41"

src/data_state_retry.rs

+176-13
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
1-
use crate::{DataState, ErrorBounds};
1+
use tracing::{error, warn};
2+
3+
use crate::{data_state::CanMakeProgress, Awaiting, DataState, ErrorBounds};
4+
use std::fmt::Debug;
25
use std::ops::Range;
3-
use std::time::Instant;
6+
use std::time::{Duration, Instant};
47

58
/// Automatically retries with a delay on failure until attempts are exhausted
69
#[derive(Debug)]
710
pub struct DataStateRetry<T, E: ErrorBounds = anyhow::Error> {
8-
/// The wrapped [`DataState`]
9-
pub inner: DataState<T, E>,
1011
/// Number of attempts that the retries get reset to
1112
pub max_attempts: u8,
1213
/// The range of milliseconds to select a random value from to set the delay
1314
/// to retry
14-
pub retry_delay_ms: Range<u16>,
15+
pub retry_delay_millis: Range<u16>,
1516
attempts_left: u8,
16-
last_attempt: Option<Instant>,
17+
inner: DataState<T, E>, // Not public to ensure resets happen as they should
18+
next_allowed_attempt: Instant,
1719
}
1820

1921
impl<T, E: ErrorBounds> DataStateRetry<T, E> {
2022
/// Creates a new instance of [DataStateRetry]
21-
pub fn new(max_attempts: u8, retry_delay_ms: Range<u16>) -> Self {
23+
pub fn new(max_attempts: u8, retry_delay_millis: Range<u16>) -> Self {
2224
Self {
2325
max_attempts,
24-
retry_delay_ms,
26+
retry_delay_millis,
2527
..Default::default()
2628
}
2729
}
@@ -31,9 +33,170 @@ impl<T, E: ErrorBounds> DataStateRetry<T, E> {
3133
self.attempts_left
3234
}
3335

34-
/// If an attempt was made the instant that it happened at
35-
pub fn last_attempt(&self) -> Option<Instant> {
36-
self.last_attempt
36+
/// The instant that needs to be waited for before another attempt is
37+
/// allowed
38+
pub fn next_allowed_attempt(&self) -> Instant {
39+
self.next_allowed_attempt
40+
}
41+
42+
/// Provides access to the inner [`DataState`]
43+
pub fn inner(&self) -> &DataState<T, E> {
44+
&self.inner
45+
}
46+
47+
/// Consumes self and returns the unwrapped inner
48+
pub fn into_inner(self) -> DataState<T, E> {
49+
self.inner
50+
}
51+
52+
/// Provides access to the stored data if available (returns Some if
53+
/// self.inner is `Data::Present(_)`)
54+
pub fn present(&self) -> Option<&T> {
55+
if let DataState::Present(data) = self.inner.as_ref() {
56+
Some(data)
57+
} else {
58+
None
59+
}
60+
}
61+
62+
/// Provides mutable access to the stored data if available (returns Some if
63+
/// self.inner is `Data::Present(_)`)
64+
pub fn present_mut(&mut self) -> Option<&mut T> {
65+
if let DataState::Present(data) = self.inner.as_mut() {
66+
Some(data)
67+
} else {
68+
None
69+
}
70+
}
71+
72+
#[cfg(feature = "egui")]
73+
/// Attempts to load the data and displays appropriate UI if applicable.
74+
///
75+
/// Note see [`DataState::egui_get`] for more info.
76+
#[must_use]
77+
pub fn egui_get<F>(
78+
&mut self,
79+
ui: &mut egui::Ui,
80+
retry_msg: Option<&str>,
81+
fetch_fn: F,
82+
) -> CanMakeProgress
83+
where
84+
F: FnOnce() -> Awaiting<T, E>,
85+
{
86+
match self.inner.as_ref() {
87+
DataState::None | DataState::AwaitingResponse(_) => {
88+
self.ui_spinner_with_attempt_count(ui);
89+
self.get(fetch_fn)
90+
}
91+
DataState::Present(_data) => {
92+
// Does nothing as data is already present
93+
CanMakeProgress::UnableToMakeProgress
94+
}
95+
DataState::Failed(e) => {
96+
ui.colored_label(
97+
ui.visuals().error_fg_color,
98+
format!("{} attempts exhausted. {e}", self.max_attempts),
99+
);
100+
if ui.button(retry_msg.unwrap_or("Restart Requests")).clicked() {
101+
self.reset_attempts();
102+
self.inner = DataState::default();
103+
}
104+
CanMakeProgress::AbleToMakeProgress
105+
}
106+
}
107+
}
108+
109+
/// Attempts to load the data and returns if it is able to make progress.
110+
///
111+
/// See [`DataState::get`] for more info.
112+
#[must_use]
113+
pub fn get<F>(&mut self, fetch_fn: F) -> CanMakeProgress
114+
where
115+
F: FnOnce() -> Awaiting<T, E>,
116+
{
117+
match self.inner.as_mut() {
118+
DataState::None => {
119+
// Going to make an attempt, set when the next attempt is allowed
120+
use rand::Rng;
121+
let wait_time_in_millis = rand::thread_rng()
122+
.gen_range(self.retry_delay_millis.clone())
123+
.into();
124+
self.next_allowed_attempt = Instant::now()
125+
.checked_add(Duration::from_millis(wait_time_in_millis))
126+
.expect("failed to get random delay, value was out of range");
127+
128+
self.inner.get(fetch_fn)
129+
}
130+
DataState::AwaitingResponse(rx) => {
131+
if let Some(new_state) = DataState::await_data(rx) {
132+
// TODO 4: Add some tests to ensure await_data work as this function assumes
133+
self.inner = match new_state.as_ref() {
134+
DataState::None => {
135+
error!("Unexpected new state received of DataState::None");
136+
unreachable!("Only expect Failed or Present variants to be returned but got None")
137+
}
138+
DataState::AwaitingResponse(_) => {
139+
error!("Unexpected new state received of AwaitingResponse");
140+
unreachable!("Only expect Failed or Present variants to be returned bug got AwaitingResponse")
141+
}
142+
DataState::Present(_) => {
143+
// Data was successfully received
144+
self.reset_attempts();
145+
new_state
146+
}
147+
DataState::Failed(_) => new_state,
148+
};
149+
}
150+
CanMakeProgress::AbleToMakeProgress
151+
}
152+
DataState::Present(_) => self.inner.get(fetch_fn),
153+
DataState::Failed(err_msg) => {
154+
if self.attempts_left == 0 {
155+
self.inner.get(fetch_fn)
156+
} else {
157+
let wait_duration_left = self
158+
.next_allowed_attempt
159+
.saturating_duration_since(Instant::now());
160+
if wait_duration_left.is_zero() {
161+
warn!(?err_msg, ?self.attempts_left, "retrying request");
162+
self.attempts_left -= 1;
163+
self.inner = DataState::None;
164+
}
165+
CanMakeProgress::AbleToMakeProgress
166+
}
167+
}
168+
}
169+
}
170+
171+
/// Resets the attempts taken
172+
pub fn reset_attempts(&mut self) {
173+
self.attempts_left = self.max_attempts;
174+
self.next_allowed_attempt = Instant::now();
175+
}
176+
177+
/// Clear stored data
178+
pub fn clear(&mut self) {
179+
self.inner = DataState::default();
180+
}
181+
182+
/// Returns `true` if the internal data state is [`DataState::Present`].
183+
#[must_use]
184+
pub fn is_present(&self) -> bool {
185+
self.inner.is_present()
186+
}
187+
188+
/// Returns `true` if the internal data state is [`DataState::None`].
189+
#[must_use]
190+
pub fn is_none(&self) -> bool {
191+
self.inner.is_none()
192+
}
193+
194+
fn ui_spinner_with_attempt_count(&self, ui: &mut egui::Ui) {
195+
ui.horizontal(|ui| {
196+
ui.spinner();
197+
ui.separator();
198+
ui.label(format!("{} attempts left", self.attempts_left))
199+
});
37200
}
38201
}
39202

@@ -42,9 +205,9 @@ impl<T, E: ErrorBounds> Default for DataStateRetry<T, E> {
42205
Self {
43206
inner: Default::default(),
44207
max_attempts: 3,
45-
retry_delay_ms: 1000..5000,
208+
retry_delay_millis: 1000..5000,
46209
attempts_left: 3,
47-
last_attempt: Default::default(),
210+
next_allowed_attempt: Instant::now(),
48211
}
49212
}
50213
}

0 commit comments

Comments
 (0)