Retrying a HTTP Reqwest with Rust#

How do you retry an HTTP request in Rust?

I have been using Rust as my only programming language for a few months now at Merkle Science. One of the things I have been building lately has been a crawler that looks through a paginated API for responses.

Note

If you’re building something like this and shipping a single light-weight binary isn’t your requirement, I’d recommend doing this in Python and using the tenacity package for retrying queries.

When building one of these, you will definitely need something that incorporates a “retry when you fail” mechanism because repeated API calls, or any API calls for that matter, are bound to fail. You might have a rate limited API, or you might just have an unreliable network.

I had both these problems and needed to build something that could repeatedly try to make a GET request.

I was using the reqwest crate to make these requests and I kept getting a reqwest::Error. Oddly enough, I was getting this both when the network failed, or when the payload didn’t have the expected schema.

To understand this, let’s first put together a simple server that will mimic unreliable behaviour.

For simplicity, I’m going to write this in Flask.

 1import flask
 2import random
 3
 4app = flask.Flask(__name__)
 5
 6@app.route("/<int:which>")
 7def index(which):
 8   if random.random() > 0.5:
 9      return {}, 404
10   if which == 1 and random.random() > 0.3:
11      return {
12         "name": "John Doe",
13         "age": 28
14      }
15   else:
16      return {
17         "uuid": "40fc1511-00ed-4021-9733-41a3ccbcf441",
18         "packet_size": 523
19      }

Save the above code snippet into a file named app.py.

To run this, create a virtual environment using Python: python3 -m venv env, activate it: source ./env/bin/activate, and install Flask: python3 -m pip install flask. Then, run the following FLASK_DEBUG=true flask run

While you don’t need to understand how this works, all you need to know is that this now provides a simple, unreliable webserver that has only 2 routes: http://127.0.0.1:5000/1 and http://127.0.0.1:5000/2.

Both these routes have a 51% chance of returning a 404, and the first URL has an added 71% chance of returning the wrong response.

Note

The term “wrong” is subjective, and for the sake of an example, I’m going to pretend that all APIs in the world always return the agreed-upon payload for a given route. Such a world doesn’t exist though.

To get this response, I wrote the following code.

Note

To get the following code running make sure you add the following lines to your Cargo.toml file’s [dependencies] section.

env_logger = "0.9.0"
log = "0.4.17"
reqwest = { version = "0.11.11", features = ["blocking", "serde_json", "json"] }
serde = { version = "1.0.137", features = ["derive"] }
serde_json = "1.0.81"
tokio = { version = "1.19.2", features = ["full", "rt"] }

You will need to setup Rust before you try to run this code.

 1use std::{fmt::Debug, time::Duration};
 2use serde::{Serialize, Deserialize};
 3
 4#[derive(Debug, Serialize, Deserialize)]
 5struct ResponseOne {
 6   name: String,
 7   age: u8
 8}
 9
10#[derive(Debug, Serialize, Deserialize)]
11struct ResponseTwo {
12   uuid: String,
13   packet_size: u16
14}
15
16fn main() {
17   let url_1 = "http://127.0.0.1:5000/1".to_string();
18   let resp_1: ResponseOne = reqwest::blocking::get(url_1).unwrap().json().unwrap();
19   log::info!("resp_1 = {resp_1:?}");
20}

Running this with RUST_LOG=info cargo run fails in one of two ways.

First, if the server is running, this fails because the payload is not as expected.

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: reqwest::Error { kind: Decode, source: Error("missing field `name`", line: 1, column: 2) }', src/main.rs:50:77
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Next, if you kill the flask server, you will get the following response.

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: reqwest::Error { kind: Request, url: Url { scheme: "http", cannot_be_a_base: false, username: "", password: None, host: Some(Ipv4(127.0.0.1)), port: Some(5000), path: "/1", query: None, fragment: None }, source: hyper::Error(Connect, ConnectError("tcp connect error", Os { code: 111, kind: ConnectionRefused, message: "Connection refused" })) }', src/main.rs:50:61
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Both of these are standard errors you will encounter when building a crawler. And the answer to both of these is: “just try again”. Rust’s error handling isn’t exactly error handling, so you cannot just bypass an error and try again for a fixed number of times without being extremely verbose about it. One way to handle this is to find a crate that does the job for you.

There were several, but only the again crate really worked the way I needed it to. I had several requirements.

  1. I need to be able to retry a query.

  2. I need to be able to wait and retry after some time.

  3. I should be able to introduce some randomness between calls, aka jitter, so that the API doesn’t realize it’s being spammed with programmed calls.

  4. I should stop after a reasonable amount of time because I am not a spammer.

The again crate works for all of these, with one slight caveat. The again::retry function, and all of its variants, call async functions, not synchronous ones. In more specific terms, calls that again::retry makes need to return a Future object, one that can be waited upon by the again crate itself. So the reqwuest::blocking calls are out of the question.

Thankfully, reqwest by default uses async calls. However, Rust’s main function is a synchronous one, and cannot await on an async call out of the box.

tokio to the rescue!

tokio comes with a runtime feature that allows us to block upon an async call within a non-async function. I won’t dive too much into that right now, but that rabbit hole led down to this code.

 1/// Get a specific typed response
 2async fn get_typed_payload<T>(url: &String) -> Result<T, reqwest::Error> where for<'de> T: serde::Deserialize<'de> {
 3   Ok(reqwest::get(url).await?.json().await?)
 4}
 5
 6fn main() {
 7   env_logger::init();
 8   let rt = tokio::runtime::Runtime::new().unwrap();
 9   let retry_policy = again::RetryPolicy::exponential(Duration::from_secs(1))
10      .with_jitter(true)
11      .with_max_delay(Duration::from_secs(3))
12      .with_max_retries(10);
13   let url_1 = "http://127.0.0.1:5000/1".to_string();
14   let resp_1 = rt.block_on({
15            let response = retry_policy.retry(|| {
16               get_typed_payload::<ResponseOne>(&url_1)
17            });
18      response
19   }).unwrap();
20   log::info!("Response 1: {resp_1:?}");
21   let url_2 = "http://127.0.0.1:5000/2".to_string();
22   let resp_2 = rt.block_on({
23            let response = retry_policy.retry(|| {
24               get_typed_payload::<ResponseTwo>(&url_2)
25            });
26      response
27   }).unwrap();
28   log::info!("Response 2: {resp_2:?}");
29}

This above code can be run with RUST_LOG=info,again=trace cargo run and will repeatedly try the API until it gets a response. The retry_policy sets a starting duration of 1 second, increases it until it reaches 3 seconds, and tries at most 10 times before actually giving up on the URL.

The get_typed_payload function is written so as to abstract away the JSON payload extraction, and because the retry function needs an async function that returns a result with a very clear Error type. Thankfully reqwest returns only one possible error type.

Some things I learnt about Rust when writing this was about generics and lifetimes.

The for<'de> T: serde::Deserialize<'de> bit denotes that the get_typed_payload function can adapt to return any type that implements serde::Deserialize. And because the value that it returns should live for the lifetime of the variable it is passed, which in this case is the url, you need to denote that as well with the for <'de> bit. One thing that this also results in is that the actual url object should be created outside of both the retry block and the block_on block, so that it lives beyond those confines.

This was a fun little exercise of doing something I’ve done a hundred times or more in Python in Rust. I’m continuing my journey merely because this way I learn how to do things that I’ve taken for granted in a language like Python, and I’m learning about Rust along the way.

Note

If you enjoy coding in Rust, or are interested enough to learn, and are interested in engineering problems in general, I’m hiring. Hit me up on Twitter or LinkedIn for a role at Merkle Science. Alternately, send us an email at careers@merklescience.com