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 througherror_fn
,- If it is, retry: After the specified time of
sleep
, the Future is re-created and executed - Otherwise, return to the user immediately
- If it is, retry: After the specified time of
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.