2022-15: new wheels backon

This week I rolled a new wheel called backon for retrying requests conveniently, which I summarized as Retry futures in backoff without effort . Today’s weekly report will talk about why this wheel was built and some experiences in the development process.

TL;DR

 use backon::Retryable; use backon::ExponentialBackoff; use anyhow:: Result ;  async fn fetch () -> Result < String > {  Ok (reqwest::get( "https://www.rust-lang.org" ). await ? .text(). await ? ) }  #[tokio::main] async fn main () -> Result < () > {  let content = fetch.retry(ExponentialBackoff::default()). await ? ;  println ! ( "fetch succeeded: {}" , contet);   Ok (()) } 

background

Retrying requests is a very common requirement, and the most widely used library in the community is backoff , which looks and feels like this:

 extern crate tokio_1 as tokio;  use backoff::ExponentialBackoff;  async fn fetch_url (url: & str ) -> Result < String , reqwest::Error > {  backoff::future::retry(ExponentialBackoff::default(), || async {  println ! ( "Fetching {}" , url);  Ok (reqwest::get(url). await ? .text(). await ? )  })  . await }  #[tokio::main] async fn main () {  match fetch_url( "https://www.rust-lang.org" ). await {  Ok (_) => println ! ( "Successfully fetched" ),  Err (err) => panic ! ( "Failed to fetch: {}" , err),  } } 

But I have a lot of dissatisfaction with it:

unfriendly usage

What backoff provides is an external function that requires a closure to be passed in:

 pub fn retry < I, E, Fn , Fut, B > (  backoff: B ,  operation: Fn ) -> Retry < impl Sleeper, B, NoopNotify, Fn , Fut > where  B: Backoff ,  Fn : FnMut () -> Fut ,  Fut: Future < Output = Result < I, Error < E >>> , 

When users use it, they need to repackage a layer externally, which will destroy the original logic call chain:

 use backoff::ExponentialBackoff;  async fn f () -> Result < (), backoff::Error <& 'static str >> {  // Business logic... Err (backoff::Error::Permanent( "error" )) }  backoff::future::retry(ExponentialBackoff::default(), f). await .err().unwrap(); 

Error handling is unnatural

When retrying a request, it is often necessary to perform some judgment and processing on errors. If a permanent error is encountered, it can be directly returned to the user instead of unnecessary retry.

The implementation scheme of backoff is to require users to wrap their own errors as Permanent or Transient errors provided by backoff:

 use backoff::{Error, ExponentialBackoff}; use reqwest::Url;  use std::fmt::Display; use std::io::{self, Read};  fn new_io_err < E: Display > (err: E ) -> io ::Error {  io::Error::new(io::ErrorKind::Other, err.to_string()) }  fn fetch_url (url: & str ) -> Result < String , Error < io::Error >> {  let op = || {  println ! ( "Fetching {}" , url);  let url = Url::parse(url)  .map_err(new_io_err)  // Permanent errors need to be explicitly constructed. .map_err(Error::Permanent) ? ;   let mut resp = reqwest::blocking::get(url)  // Transient errors can be constructed with the ? operator // or with the try! macro. No explicit conversion needed // from E: Error to backoff::Error; .map_err(new_io_err) ? ;   let mut content = String ::new();  let _ = resp.read_to_string( & mut content);  Ok (content)  };   let backoff = ExponentialBackoff::default();  backoff::retry(backoff, op) }  fn main () {  match fetch_url( "https::///wrong URL" ) {  Ok (_) => println ! ( "Successfully fetched" ),  Err (err) => panic ! ( "Failed to fetch: {}" , err),  } } 

The disadvantage of this is that it is intrusive to the user’s business.

Custom Backoff Complex

User-defined Backoff needs to implement the backoff::backoff::Backoff method:

 pub trait Backoff {  fn next_backoff ( & mut self) -> Option < Duration > ;   fn reset ( & mut self) { ... } } 

current status of the project

The current maintenance status of backoff is not very good. The last commit of the master branch is still in the release of 0.4.1-alpha.0 . There is no immediate response to the community’s Issue and PR. The code is still in the 2018 edition and has not been upgraded to 2021.

Combining the above factors, coupled with the simple logic of backoff itself and the small amount of code, it is better to recreate a library that meets all requirements instead of investing in the improvement of backoff.

backon is coming!

The name backon implies that its design trade-offs are the exact opposite of backoff:

natural way of use

The core expectation of deciding to develop backon is to be able to retry a Future very naturally, reducing the invasiveness of the original code. So I chose to do the complex work in a backon so that the user can naturally retry a future:

 async fn fetch() -> Result<String> { Ok(reqwest::get("https://www.rust-lang.org").await?.text().await?) } #[tokio::main] async fn main() -> Result<()> { - let content = fetch().await?; + let content = fetch.retry(ExponentialBackoff::default()).await?; Ok(()) }

Zero-overhead error handling

Users don’t need to pay any extra price for error handling, don’t need to wrap it into any new type, just pass in the condition of the judgment:

 #[tokio::main] async fn main() -> Result<()> { let content = fetch .retry(ExponentialBackoff::default()) + .with_error_fn(|e| e.to_string() == "retryable").await?; println!("fetch succeeded: {}", content); Ok(()) }

Iterator-based Backoff abstraction

Backoff is essentially an iterator that returns Duration, and backon is designed based on this principle:

 pub trait Backoff: Iterator < Item = Duration > + Clone { } 

Any struct that implements Iterator<Item = Duration> can be passed into retry as Backoff, which greatly simplifies the implementation of Backoff.

Combining all the above features, we get a brand new backoff implementation~

accomplish

backon adds retry support by adding trait Retryable and implementing Retryable for closures:

 pub trait Retryable < B: Backoff , T, E, Fut: Future < Output = Result < T, E >> , FutureFn: FnMut () -> Fut > {  fn retry (self, backoff: B ) -> Retry < B, T, E, Fut, FutureFn > ; }  impl < B, T, E, Fut, FutureFn > Retryable < B, T, E, Fut, FutureFn > for FutureFn where  B: Backoff ,  Fut: Future < Output = std::result:: Result < T, E >> ,  FutureFn: FnMut () -> Fut , {  fn retry (self, backoff: B ) -> Retry < B, T, E, Fut, FutureFn > {  Retry::new(self, backoff)  } } 

The retry method accepts a closure that returns Future<Output = Result<T, E>> , and generates a new structure Retry , which holds backoff , Retry , error_fn and internal state future_fn the same time.

Retry will create a future through future_fn and execute it:

  • Returning Ok(_) will immediately return to the user
  • If Err(e) is obtained, check whether e is a retryable error through error_fn ,
    • If it is, retry: After the specified time of sleep , the Future is re-created and executed
    • Otherwise, return to the user immediately

The whole process happens on the stack (except tokio’s Sleep is Box::pin because it is too fat), there is no extra overhead.

The specific implementation can be found in backon/src/retry.rs

inspired

In the process of implementing retry , I took some detours and understood the problem as how to retry a Future:

 pub trait Retryable < B: Policy , F: Fn ( & Self::Error) -> bool > : TryFuture + Sized {  fn retry (self, backoff: B , handle: F ) -> Retry < Self, B, F > {  Retry {  inner: self ,  backoff,  handle,  sleeper: None ,  }  } } 

But this is obviously wrong: after a Future returns Poll::Ready , we can no longer go to poll. To retry a Future, we must capture the closure that created the Future to recreate a new Future.

Although this was a failed attempt, I created a PR for it: Failed demo for retry: we can’t retry a future directly . Because I recently realized that PR is not the end of a job, rather, the most important job starts with a PR. Through PR, we can communicate with the community and exchange our real ideas (implemented in code, not empty concepts), educate and learn from each other, and give more inspiration to future work. In fact, it was through this failed PR that I found the right direction and successfully implemented today’s retry functionality in the next version.

In the future, I will also submit more of my unfinished or in-progress work as PR, and try to see if it will help to write better code~

Summarize

backon is a brand new backoff retry library that implements retry in a zero-overhead way, solving the usability problems of other projects.

Welcome to try and give feedback~

This article is reproduced from: https://xuanwo.io/reports/2022-15/
This site is for inclusion only, and the copyright belongs to the original author.

Leave a Comment