#exponential-backoff #retry #backoff #tokio #async

retry-if

A tokio-compatible attribute-macro for decorating methods and functions with an exponential backoff

2 unstable releases

new 0.2.0 May 21, 2024
0.1.0 Apr 18, 2024

#496 in Asynchronous

Download history 120/week @ 2024-04-16

120 downloads per month

MIT license

13KB

Retry-If

badgeLicense: MIT

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.

Example: Retrying a Result-producing Function on Err(...)

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:?}");
}

Tracing

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.

Limitations

#[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 as async 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 and self may no longer exist after the first call
  • Functions that rely on the try (?) operator on Option

Example: Non-Working Function That Consumes Self

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()
    }
}

Example: Non-Working Function That Uses Try on Option

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
    }
}

Contributions

Please reach out if you encounter edge cases, have any suggestions for improving the API, or have any clarifications that can be made.

Dependencies

~2.7–9.5MB
~76K SLoC