这周搓了一个新轮子叫做 backon, 用于方便地重试请求,我将其概括为 Retry futures in backoff without effort。今天这份周报就来聊聊为什么要造这个轮子以及在开发过程中的一些经历。

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

背景

重试请求是一个很常见的需求,社区使用最广泛的库是 backoff,看起来的感觉是这样:

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

但是我对它有很多不满意的地方:

用法不友好

backoff 提供的是一个外部的函数,要求传入一个闭包:

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

用户在使用的时候需要在外部重新包一层,会破坏原始的逻辑调用链:

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

错误处理不自然

重试请求时经常需要对错误进行一些判断和处理,遇到永久的错误可以直接返回给用户而不是进行无谓的重试。

backoff 的实现方案是要求用户将自己的错误包装成 backoff 提供的 Permanent 或者 Transient 错误:

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

这样做的缺点是对用户的业务存在侵入性。

自定义 Backoff 复杂

用户自定义 Backoff 需要实现 backoff::backoff::Backoff 方法:

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

    fn reset(&mut self) { ... }
}

项目当前状态

backoff 目前的维护状况不是非常良好,master 分支最后一个 commmit 停留在发布 0.4.1-alpha.0,社区的 Issue 和 PR 也没有即时响应,代码还停留在 2018 edition 没有升级到 2021。

综合上面的各种因素,再加上 backoff 本身逻辑简单,代码量不大,与其投入力量到 backoff 的改进,不如自己重新造一个满足所有需求的库。

backon is coming!

backon 的命名就意味着它的设计取舍与 backoff 完全相反:

自然的使用方法

决定开发 backon 的最核心期望就是能非常自然的重试一个 Future,减少对原始代码的侵入性。所以我选择在 backon 中完成复杂的工作,使得用户可以自然地重试一个 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(())
}

零开销的错误处理

用户不需要为错误处理付出任何额外的代价,不需要包装成任何新类型,只需要传入判断的条件:

#[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 的 Backoff 抽象

Backoff 本质上就是一个返回 Duration 的迭代器,backon 就是基于这一原则设计的:

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

任何实现了 Iterator<Item = Duration> 的结构体都可以作为 Backoff 传入 retry,这极大地简化了 Backoff 的实现。

综合以上的所有的特性,我们得到了一个全新的 backoff 实现~

实现

backon 通过增加 trait Retryable 并为闭包实现 Retryable 的方式来增加 retry 的支持:

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

retry 方法接受一个会返回 Future<Output = Result<T, E>> 的闭包,并生成新的结构体 Retry,而 Retry 结构体会同时持有 backofferror_fnfuture_fn 以及内部的 state

Retry 会通过 future_fn 来创建 future 并执行它:

  • 返回 Ok(_) 就立刻返回给用户
  • 得到了 Err(e) 则通过 error_fn 检查 e 是否为 retryable error,
    • 是就进行 retry:sleep 指定时间后重新创建 Future 并执行
    • 否则立刻返回给用户

整个过程都是发生在栈上的(除了 tokio 的 Sleep 因为太肥被 Box::pin 了),没有额外的开销。

具体的实现可以参见 backon/src/retry.rs

启发

在实现 retry 的过程中我走了一些弯路,将问题理解成了如何去重试一个 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,
        }
    }
}

但这样显然是错误的:一个 Future 在返回了 Poll::Ready 之后,我们就不能再去 poll 了。想要重试一个 Future,我们必须要捕获创建这个 Future 的闭包来重新创建一个新 Future。

尽管这是一个失败的尝试,但是我依然为它创建了一个 PR: Failed demo for retry: we can't retry a future directly。因为我最近认识到 PR 并不是一项工作的终结,相反,最重要的工作恰恰始于一个 PR。通过 PR 我们能够跟社区沟通和交流自己的真实想法(落地成代码,而不是空泛的概念),能够互相教育和学习,能够给予未来的工作更多启迪。事实上,正是通过这个失败的 PR,我找到了正确的方向,并且成功的在下个版本中实现了如今的 retry 功能。

在未来我也会更多地把自己未完成或者进行中的工作也提交成 PR,尝试一下是不是会更有助于写出更好的代码~

总结

backon 是一个全新的 backoff retry 库,通过零开销的方式实现了 retry,解决了其他项目的易用性问题。

欢迎大家试用和反馈意见~