A predicate-based retry decorator for retrying arbitrary functions using an exponential backoff strategy.
This library aims to make decorating your code with a retry strategy as easy as possible, all that's required is a defined retry strategy and a function that determines if the output of your code needs to be retried.
The below example sets up a basic retry configuration that will retry up to five times, waiting at first 1 second, then 2 seconds, 4 seconds, etc. There is no configured maximum time to retry across all attempts (t_wait_max), nor is there any maximum waiting time on each backoff (backoff_max).
use retry_if::{retry, ExponentialBackoffConfig};
use std::num::TryFromIntError;
use std::time::Duration;
use tokio::time::{pause, Instant};
const BACKOFF_CONFIG: ExponentialBackoffConfig = ExponentialBackoffConfig {
max_retries: 5,
t_wait: Duration::from_secs(1),
backoff: 2.0,
t_wait_max: None,
backoff_max: None,
};
// this takes an address of the same type as the output of the decorated function.
// It returns true if the function should be retried based on the result
fn retry_if(result: &Result<i64, TryFromIntError>) -> bool {
result.is_err()
}
#[retry(BACKOFF_CONFIG, retry_if)]
async fn fallible_call() -> Result<i64, TryFromIntError> {
// this will always produce a TryFromIntError, triggering a retry
i64::try_from(i128::MAX)
}
#[tokio::main]
async fn main() {
let start = Instant::now();
let _ = fallible_call().await;
let end = Instant::now();
let elapsed = end - start;
// expected waits are 1s, 2s, 4s, 8s, 16s = 31s
assert!(elapsed > Duration::from_secs(31));
assert!(elapsed < Duration::from_millis(31100));
println!("Total test time: {elapsed:?}");
}
The crate exposes tracing
as a feature to enable logging using the tokio tracing
library's tracing::info!
for
each of the retry attempts. These currently take the form of: Sleeping {Duration:?} on attempt {i32}
. The output
traces will take on whatever instrumented scope is given to the parent function.
#[retry(...)]
can decorate almost all cases of async functions. This includes:
- free functions such as
async fn do_thing() -> i32
- methods in impl blocks such as
async fn do_thing(&mut self) -> bool
- trait implementations in
impl <Trait> for <Struct> {}
blocks, such asasync fn do_thing(&self) -> String
Use cases that retry-if cannot be applied to include:
- Functions that take and consume
self
, since ownership is passed andself
may no longer exist after the first call - Functions that rely on the try (?) operator on
Option
A non-working example of this is shown below, where to_thing()
consumes self
, making a second call impossible.
struct Thing {}
struct Other {}
impl Thing {
#[retry(...)]
async fn to_thing(self) -> Other {
self.into()
}
}
A non-working example of this is shown below, where the function uses the try operator to exit early with an Option
.
This cannot work because the code is expanded with Result<T, E>
as the primary use case, and it's not possible
to determine if the ?
applies to a Result
or an Option
when looking at TokenStreams in the macro, making expansion
to both impossible when parsing.
struct Thing {}
struct Other {}
impl Thing {
#[retry(...)]
async fn do_thing(self) -> Option<i32> {
// compilation fails here because this is expanded to match on `get_data()` and the match arms are Ok & Err
// instead of Some & None
let data = get_data()?;
data * 2
}
}
Please reach out if you encounter edge cases, have any suggestions for improving the API, or have any clarifications that can be made.