This demo will cover two different implementations of similar logic, retrying fallible functions. For a more detailed walkthrough of building these macros, check out the accompanying blog article.
retry!
- Wraps a given function with retry logc (optional number of retries can be given)
- A progression of the previous
timeit!
macro, with added logic defined within themacro_rules!
retryable!
- We'll build a
Retryable
type with flexibleRetryStrategy
options (retries, delay, etc.) Retryable
can be used without a macro, but requires verbose setupretryable!
macro will warp the setup logic, offering rules for passing strategy options
- We'll build a
Functions can fail. Some failures are persistent, like trying to open an invalid file path or parsing numeric values out of a string that doesn't contain numbers. Other failures are intermittent, like attempting to read data from a remote server. In the intermittent case it can be useful to have some logic to retry the attempted call in hopes for a successful result. This is exactly what our retry!
and retryable!
macros will do!
The first macro, retry!
, contains all the retry logic in the macro, expanding around the passed in function or closure.
To support wrapping closures and functions, and to keep the retry!
macro_rules implementation clean, we'll create another macro (_wrapper!
) for the following use-cases:
let res = retry!(|| { sometimes_fail(10) });
assert!(res.is_ok());
let res = retry!(sometimes_fail, 10; retries = 3);
assert!(res.is_ok());
The implementation with match rules for each case looks like:
macro_rules! _wrapper {
// Single expression (like a function name or closure)
($f:expr) => {{
$f()
}};
// Variadic number of args (Allowing trailing comma)
($f:expr, $( $args:expr $(,)? )* ) => {{
$f( $( $args, )* )
}};
}
Something we learned with the timeit!
macro was that we can match on repeating items, and then add code-expansion for each item. We'll use that same trick here to match on multiple arguments for the case of a function & args being passed into retry!
:
macro_rules! _wrapper {
($f:expr) => {{ /* code from previous section */ }};
// Variadic number of args (Allowing trailing comma)
($f:expr, $( $args:expr $(,)? )* ) => {{
$f( $($args,)* )
}};
}
There's a lot going on in this single line so let's break it down:
$f:expr
: The function passed in for retrying,
: Comma separator before the function arguments$( .. )*
: Anything in these parentheses can repeat (zero or more times, like*
in regex)$args:expr
: Capture each repeating expr into$args
$(,)?
: Allow optional commas (? == 0 or 1 times, likeregex
)
This match rule will capture something like _wrapper!(my_func, 10, 20)
into something that resembles:
$f
==my_func
$args
==[10, 20]
And let's break down the expansion: $f( $( $args, )* )
:
$f( ... )
: Function name, with literal parenthesis wrapping whatever is inside$( ... )*
: Repeat what's inside per expr in$args
$args,
: Write out an expr, followed by a literal comma
Which expands to:
my_func(10, 20,)
The retry!
macro contained all the retry logic in the macro. As the logic gains capabilities (like delay time and backoff strategy), the macro code grows and becomes more complex. Another approach to retrying functions is to create code to handle retries outside of macros, and use a macro to make setting up the usage of our retry logic easier.
Forgoing macros for a bit, let's setup some retry structs and implementations. First is a Retryable
struct to contain our function/closure to retry, and a RetryStrategy
with options for retrying (number of retries, delay, etc.):
pub struct Retryable<F, T, E>
where
F: FnMut() -> Result<T, E>,
{
inner: F,
strategy: RetryStrategy,
}
/// Specification for how the retryable should behave
pub struct RetryStrategy {
retries: usize,
delay: RetryDelay,
}
pub enum RetryDelay {
Fixed(std::time::Duration),
// TODO: More options here
}
The core of our implementation for this struct looks like the retry logic from retry!
, although we now use the delay options from RetryStrategy
.
impl<F, T, E> Retryable<F, T, E>
where
F: FnMut() -> Result<T, E>,
{
/// Start calling the wrapped function, responding to Errors
/// as the specified strategy dictates
pub fn try_call(&mut self) -> Result<T, E> {
let mut retries = self.strategy.retries;
let mut delay_time = Duration::from_millis(0);
loop {
std::thread::sleep(delay_time);
let res = (self.inner)();
if res.is_ok() {
break res;
}
if retries > 0 {
retries -= 1;
delay_time = self.next_run_time();
continue;
}
break res;
}
}
fn next_run_time(&self) -> Duration {
match self.strategy.delay {
RetryDelay::Fixed(delay) => delay,
}
}
}
Breaking out this logic into the RetryStrategy
gives us much more flexibility with retrying, but now we have a problem with a more tedius setup:
let strategy = RetryStrategy::default().with_retries(3).to_owned();
let mut r = Retryable::new(succeed_after!(2), strategy);
let res = r.try_call();
assert!(res.is_ok());
Luckily for us we have an awesome tool in the toolbox that we can use to make this setup much easier: a macro! Using some similar matching rules we used with retry!
, we can setup a very flexible macro to allow for optional specification of retries:
macro_rules! retryable {
// Take a closure with retry count
// ```ignore
// retryable!(|| { do_something(1, 2, 3, 4) }; retries=2);
// ```
($f:expr; retries=$r:expr) => {{
let _strategy = RetryStrategy::default().with_retries($r).to_owned();
let mut _r = Retryable::new($f, _strategy);
_r.try_call()
}};
// Take a function ptr, variadic args, and retry count
// ```ignore
// retryable!(my_fallible_func, 0, "something"; retries=5);
// ```
($( $args:expr $(,)? )+; retries=$r:expr) => {{
retryable!(|| { _wrapper!($($args,)*)}; retries=$r)
}};
Check out the full implementation which adds options for passing retries
and delay
args for more advanced usage that is similar our previous retry!
macro:
let res = retryable!(sometimes_fail, 10; retries = 15; delay = 1);
assert!(res.is_ok());
let res = retryable!(|| {sometimes_fail(10)}; retries = 15; delay = 1);
assert!(res.is_ok());