Skip to content
Select theme

Making it fast: async with tokio

This step finishes: the blocking client. Since hit learned to save and replay requests, everything it does over the network happens one request at a time — reqwest::blocking holds the thread until the response lands. That is fine for one URL and wasteful for five. This step swaps the blocking client for the async one and turns hit run into a batch command: name several saved requests, and they all go out at once, with one summary line each.

You need the project as the previous article left it. If you are starting from scratch, that state is tagged cli-ch09 in the examples repository:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/cli-tool
git checkout cli-ch09

The blocking client is a cargo feature, so going async starts in Cargo.toml:

Terminal window
cargo remove reqwest
cargo add reqwest
cargo add tokio --features macros,rt-multi-thread,time
cargo add futures
Cargo.toml
[dependencies]
clap = { version = "4", features = ["derive"] }
comfy-table = "8.0.1"
directories = "6.0.0"
futures = "0.3.34"
indicatif = "0.18.6"
owo-colors = { version = "4.4.0", features = ["supports-colors"] }
reqwest = "0.13.5"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
thiserror = "2"
tokio = { version = "1.53.3", features = ["macros", "rt-multi-thread", "time"] }
toml = "1.1.8"

Removing the blocking feature removes the reqwest::blocking module — re-adding reqwest bare gives you the async client, which has the same type names under reqwest:: directly. tokio brings the runtime: macros for the attribute that turns main into an async entry point, rt-multi-thread for the scheduler that runs tasks across threads, and time because the demo later in this article sleeps on a tokio timer. futures provides join_all, the function that waits for many futures at once.

main gains two words, and a runtime to back them. The shape, with a stub run() standing in for the real one so the demo stands on its own:

running

#[tokio::main] rewrites main into a call that builds the tokio runtime and drives run(cli).await on it. The return value passes straight through, so hit’s real main — same match, same because: chain, run(cli) instead of run() — behaves exactly as it did before; the whole file is at the end of this article. The imports change at the same time — reqwest::blocking::{Client, RequestBuilder, Response} becomes reqwest::{Client, RequestBuilder, Response} — and every point that waits on the network gains .await.

send() shows how small those changes are. The error classification moves out of the closure into a helper so the batch can reuse it; otherwise the edit is one word — request.send().await. reqwest::Error is the same type for the async client, so classify() keeps doing what the closure did in the error-handling step: timeouts and connection failures become their own HitError variants, everything else falls through to the transparent Request wrapper.

The spinner survives too. start_spinner() keeps its watchdog thread — the only edit is a comment inside it. When the request blocked the thread, a plain thread decided whether the wait was long enough to draw a spinner; the request no longer blocks anything (send().await yields the task, and the runtime runs other work), but the spinner still lives on its own plain thread, because indicatif knows nothing about async and does not need to. finish_and_clear() still runs the moment the response arrives.

hit run currently takes one name. Clap collects a repeated positional into a Vec<String>, so the subcommand takes any number of names, and one name keeps the old full-output behaviour — spinner, status colour, header table. The header parsing that send_get() used to do inline moves into a build_request() helper, because the batch needs the same -H-style parsing for saved headers and both callers share it.

hit run --help shows the new surface (-q keeps cargo’s progress lines out of the output; every command on this page uses it):

Terminal window
cargo run -q -- run --help
Send saved requests; more than one runs concurrently
Usage: hit run [OPTIONS] [NAMES]...
Arguments:
[NAMES]... The names they were saved under
Options:
-v, --verbose Print what we are about to do
-h, --help Print help

join_all: run them together, sort it out after

Section titled “join_all: run them together, sort it out after”

The batch is built from futures and joined with join_all. To see the shape of that without the network, three sleeps:

[300, 300, 300]
elapsed: 306 ms

Three 300 ms sleeps, 306 ms total — not 900. The three futures run on the same runtime at the same time, and join_all waits until every one has finished before returning their results in order. That ordering is why the batch can print its summary in the order you typed the names, no matter which request actually answers first.

run_many() does the work — it is in the full listing below. The shape matters more than the lines:

  • Load first, send second. Every name is resolved against the data directory before anything goes on the wire, so hit run slow1 nope stops with no saved request named "nope" and never fires slow1.
  • Each future keeps its own Result. A ? inside the async block would return the first failure and throw away everything the other requests are doing. Instead each future measures its own elapsed time and carries its error out; the successes print their line as the loop walks the results, and the first failure is returned once every request has landed, so it travels out through the same hit: and because: chain as every other hit error. Only the first failure is reported — keep a Vec instead of the single Option if you need them all.
  • No spinner. Several spinners would fight over one terminal, and the summary is the output. Single-name runs keep the spinner, because that path never calls run_many().

join_all returns the results in order, which is why the summary lists the names in the order you typed them, no matter which request answered first. Two small helpers keep each line short: fmt_bytes() prints 273 B under a kilobyte and one decimal in KB above it, fmt_elapsed() prints whole milliseconds under a second and two decimals in seconds from there.

Everything together, src/main.rs:

src/main.rs
use std::collections::BTreeMap;
use std::error::Error;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::process::ExitCode;
use std::thread;
use std::time::{Duration, Instant};
use clap::{Parser, Subcommand};
use comfy_table::presets::UTF8_FULL;
use comfy_table::Table;
use futures::future::join_all;
use indicatif::{ProgressBar, ProgressStyle};
use owo_colors::{OwoColorize, Stream};
use reqwest::{Client, RequestBuilder, Response};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Requests that answer inside this window never show a spinner.
const SLOW_AFTER: Duration = Duration::from_millis(200);
#[derive(Parser)]
#[command(name = "hit", version, about = "A tiny command-line HTTP client")]
struct Cli {
/// Print what we are about to do
#[arg(short, long, global = true)]
verbose: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Send a GET request
Get {
/// The URL to request
url: String,
/// Send this header, for example -H "Accept: application/json"
#[arg(short = 'H', long = "header")]
headers: Vec<String>,
},
/// Send a POST request with a body
Post {
/// The URL to request
url: String,
/// Request body
#[arg(short, long)]
body: Option<String>,
},
/// Save a GET request to reuse later
Save {
/// Name to store the request under
name: String,
/// The URL to request
url: String,
/// Send this header, for example -H "Accept: application/json"
#[arg(short = 'H', long = "header")]
headers: Vec<String>,
},
/// Send saved requests; more than one runs concurrently
Run {
/// The names they were saved under
names: Vec<String>,
},
}
#[derive(Debug, Error)]
enum HitError {
#[error("{url} did not answer within {timeout:?}")]
Timeout {
url: String,
timeout: Duration,
#[source]
source: reqwest::Error,
},
#[error("could not reach {url}")]
Connect {
url: String,
#[source]
source: reqwest::Error,
},
#[error("invalid --header {header:?}: expected \"Name: value\"")]
InvalidHeader { header: String },
#[error("could not read {path}: {source}")]
ConfigRead {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not parse {path}")]
ConfigParse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("invalid name {name:?}: use a plain file name, no path separators")]
InvalidName { name: String },
#[error("could not locate a data directory")]
NoDataDir,
#[error("could not write {path}: {source}")]
SaveWrite {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not encode the request for {name:?}")]
SaveEncode {
name: String,
#[source]
source: serde_json::Error,
},
#[error("no saved request named {name:?}")]
NoSuchRequest { name: String },
#[error("could not read {path}: {source}")]
LoadRead {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not parse {path}")]
LoadParse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error(transparent)]
Request(#[from] reqwest::Error),
}
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct Config {
/// Request timeout in seconds.
timeout: u64,
/// How responses are printed: "table" (default) or "plain".
format: Format,
/// Headers sent with every request, unless -H overrides them.
headers: BTreeMap<String, String>,
}
impl Default for Config {
fn default() -> Self {
Config {
timeout: 10,
format: Format::Table,
headers: BTreeMap::new(),
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
enum Format {
#[default]
Table,
Plain,
}
#[derive(Debug, Serialize, Deserialize)]
struct SavedRequest {
url: String,
headers: Vec<String>,
}
fn requests_dir() -> Option<PathBuf> {
let dirs = directories::BaseDirs::new()?;
Some(dirs.data_dir().join("hit"))
}
fn request_path(name: &str) -> Result<PathBuf, HitError> {
if name.is_empty() || name.contains('/') || name.contains('\\') {
return Err(HitError::InvalidName {
name: name.to_string(),
});
}
let Some(dir) = requests_dir() else {
return Err(HitError::NoDataDir);
};
Ok(dir.join(format!("{name}.json")))
}
fn save_request(name: &str, url: &str, headers: &[String]) -> Result<PathBuf, HitError> {
let path = request_path(name)?;
let saved = SavedRequest {
url: url.to_string(),
headers: headers.to_vec(),
};
let text = serde_json::to_string_pretty(&saved).map_err(|source| HitError::SaveEncode {
name: name.to_string(),
source,
})?;
let Some(dir) = path.parent() else {
return Err(HitError::NoDataDir);
};
fs::create_dir_all(dir).map_err(|source| HitError::SaveWrite {
path: path.clone(),
source,
})?;
fs::write(&path, text).map_err(|source| HitError::SaveWrite {
path: path.clone(),
source,
})?;
Ok(path)
}
fn load_request(name: &str) -> Result<SavedRequest, HitError> {
let path = request_path(name)?;
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Err(HitError::NoSuchRequest {
name: name.to_string(),
});
}
Err(e) => return Err(HitError::LoadRead { path, source: e }),
};
serde_json::from_str(&text).map_err(|source| HitError::LoadParse { path, source })
}
fn load_config() -> Result<Config, HitError> {
let Some(dirs) = directories::UserDirs::new() else {
return Ok(Config::default());
};
let path = dirs.home_dir().join(".hit.toml");
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Config::default()),
Err(e) => return Err(HitError::ConfigRead { path, source: e }),
};
toml::from_str(&text).map_err(|source| HitError::ConfigParse { path, source })
}
#[tokio::main]
async fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("hit: {e}");
let mut source = e.source();
while let Some(err) = source {
eprintln!(" because: {err}");
source = err.source();
}
ExitCode::FAILURE
}
}
}
async fn run(cli: Cli) -> Result<(), HitError> {
let config = load_config()?;
let timeout = Duration::from_secs(config.timeout);
let client = Client::builder().timeout(timeout).build()?;
match cli.command {
Command::Get { url, headers } => {
if cli.verbose {
eprintln!("GET {url}");
for header in &headers {
eprintln!(" header: {header}");
}
}
send_get(&client, &config, timeout, url, headers).await?;
}
Command::Post { url, body } => {
if cli.verbose {
eprintln!("POST {url}");
}
let mut request = add_default_headers(client.post(&url), &config.headers);
if let Some(body) = body {
request = request.body(body);
}
let response = send(request, &url, timeout).await?;
print_response(response, &config.format).await?;
}
Command::Save { name, url, headers } => {
let path = save_request(&name, &url, &headers)?;
println!("saved {name} to {}", path.display());
}
Command::Run { names } => {
if let [name] = names.as_slice() {
let saved = load_request(name)?;
if cli.verbose {
eprintln!("GET {}", saved.url);
for header in &saved.headers {
eprintln!(" header: {header}");
}
}
send_get(&client, &config, timeout, saved.url, saved.headers).await?;
} else {
run_many(&client, &config, timeout, names).await?;
}
}
}
Ok(())
}
async fn send_get(
client: &Client,
config: &Config,
timeout: Duration,
url: String,
headers: Vec<String>,
) -> Result<(), HitError> {
let request = build_request(client, config, &url, &headers)?;
let response = send(request, &url, timeout).await?;
print_response(response, &config.format).await?;
Ok(())
}
fn build_request(
client: &Client,
config: &Config,
url: &str,
headers: &[String],
) -> Result<RequestBuilder, HitError> {
let mut request = add_default_headers(client.get(url), &config.headers);
for header in headers {
let (name, value) = header
.split_once(':')
.ok_or_else(|| HitError::InvalidHeader { header: header.clone() })?;
request = request.header(name.trim(), value.trim());
}
Ok(request)
}
async fn run_many(
client: &Client,
config: &Config,
timeout: Duration,
names: Vec<String>,
) -> Result<(), HitError> {
let mut jobs = Vec::new();
for name in names {
let saved = load_request(&name)?;
let request = build_request(client, config, &saved.url, &saved.headers)?;
jobs.push((name, saved.url, request));
}
// Every request runs at once. Each future measures its own elapsed time
// and keeps its error instead of returning early with one.
let futures = jobs.into_iter().map(|(name, url, request)| async move {
let start = Instant::now();
let result = match request.send().await {
Ok(response) => {
let status = response.status();
match response.text().await {
Ok(body) => Ok((status, body.len())),
Err(e) => Err(classify(e, &url, timeout)),
}
}
Err(e) => Err(classify(e, &url, timeout)),
};
(name, start.elapsed(), result)
});
let mut error = None;
for (name, elapsed, result) in join_all(futures).await {
match result {
Ok((status, bytes)) => println!(
"{name:<10} {} · {} · {}",
paint_status(status),
fmt_bytes(bytes),
fmt_elapsed(elapsed),
),
Err(e) => error = error.or(Some(e)),
}
}
match error {
Some(e) => Err(e),
None => Ok(()),
}
}
fn classify(error: reqwest::Error, url: &str, timeout: Duration) -> HitError {
if error.is_timeout() {
HitError::Timeout {
url: url.to_string(),
timeout,
source: error,
}
} else if error.is_connect() {
HitError::Connect {
url: url.to_string(),
source: error,
}
} else {
HitError::Request(error)
}
}
fn fmt_bytes(bytes: usize) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else {
format!("{:.1} KB", bytes as f64 / 1024.0)
}
}
fn fmt_elapsed(elapsed: Duration) -> String {
if elapsed.as_secs() > 0 {
format!("{:.2} s", elapsed.as_secs_f64())
} else {
format!("{} ms", elapsed.as_millis())
}
}
fn add_default_headers(
request: RequestBuilder,
headers: &BTreeMap<String, String>,
) -> RequestBuilder {
let mut request = request;
for (name, value) in headers {
request = request.header(name, value);
}
request
}
async fn send(request: RequestBuilder, url: &str, timeout: Duration) -> Result<Response, HitError> {
let spinner = start_spinner(url);
let result = request
.send()
.await
.map_err(|error| classify(error, url, timeout));
spinner.finish_and_clear();
result
}
fn start_spinner(url: &str) -> ProgressBar {
let spinner = ProgressBar::new_spinner();
spinner.set_style(ProgressStyle::with_template("{spinner:.green} {msg}").unwrap());
// The request no longer blocks a thread, but the spinner still gets its
// own plain thread: indicatif knows nothing about async, and it does not
// need to. Nothing is drawn until the watchdog speaks: a fast request
// leaves no trace on the terminal.
let url = url.to_string();
let watchdog_spinner = spinner.clone();
thread::spawn(move || {
thread::sleep(SLOW_AFTER);
if !watchdog_spinner.is_finished() {
watchdog_spinner.set_message(format!("waiting for {url}"));
watchdog_spinner.enable_steady_tick(Duration::from_millis(100));
}
});
spinner
}
async fn print_response(response: Response, format: &Format) -> Result<(), HitError> {
println!("{}", paint_status(response.status()));
if let Format::Table = format {
let mut table = Table::new();
table.load_style(UTF8_FULL);
table.set_header(vec!["Header", "Value"]);
for (name, value) in response.headers() {
table.add_row(vec![name.as_str(), value.to_str().unwrap_or("<binary>")]);
}
println!("{table}");
}
println!();
let body = response.text().await?;
println!("{body}");
Ok(())
}
fn paint_status(status: reqwest::StatusCode) -> String {
let text = status.to_string();
if status.is_success() {
text.if_supports_color(Stream::Stdout, |t| t.green()).to_string()
} else if status.is_redirection() {
text.if_supports_color(Stream::Stdout, |t| t.cyan()).to_string()
} else if status.is_client_error() {
text.if_supports_color(Stream::Stdout, |t| t.yellow()).to_string()
} else if status.is_server_error() {
text.if_supports_color(Stream::Stdout, |t| t.red()).to_string()
} else {
text
}
}

The runs below point HOME at a scratch directory so they cannot collide with real saved requests on this machine; on yours you can skip those exports. RUSTUP_HOME and CARGO_HOME point back at the real toolchain — rustup reads HOME too, and without them it would install a fresh copy of the compiler inside the scratch directory. The saves:

Terminal window
ORIG_HOME="$HOME"
export HOME=/tmp/hit-demo10 RUSTUP_HOME="$ORIG_HOME/.rustup" CARGO_HOME="$ORIG_HOME/.cargo"
cargo run -q -- save slow1 https://httpbin.org/delay/2
cargo run -q -- save slow2 https://httpbin.org/delay/2
cargo run -q -- save slow3 https://httpbin.org/delay/2
saved slow1 to /tmp/hit-demo10/Library/Application Support/hit/slow1.json
saved slow2 to /tmp/hit-demo10/Library/Application Support/hit/slow2.json
saved slow3 to /tmp/hit-demo10/Library/Application Support/hit/slow3.json

httpbin.org/delay/2 answers after about two seconds; on the day of this run it took closer to three. Three of those one at a time is nine seconds. Batched:

Terminal window
time cargo run -q -- run slow1 slow2 slow3
slow1 200 OK · 273 B · 2.97 s
slow2 200 OK · 273 B · 2.97 s
slow3 200 OK · 273 B · 3.07 s
real 0m3.563s
user 0m0.132s
sys 0m0.073s

Each line reports its own request: status, body size, and how long that one request took. The wall clock is the tell — real is three and a half seconds for three requests that each took about three, because they were in flight together. (-q silences cargo’s own progress lines so they do not pollute the timing; a single name, hit run slow1, still gives you the full coloured output and header table from the last two steps.)

A batch member that fails does not take the others down:

Terminal window
cargo run -q -- save offline https://no-such-host.invalid
cargo run -q -- run slow1 offline
saved offline to /tmp/hit-demo10/Library/Application Support/hit/offline.json
slow1 200 OK · 273 B · 2.92 s
hit: could not reach https://no-such-host.invalid
because: error sending request for url (https://no-such-host.invalid/)
because: client error (Connect)
because: Connection reset by peer (os error 54)

slow1’s line is already printed when the error surfaces — the batch collected every result before deciding what to do with the failure. The exit code is 1, and only the first failure is reported; if you need them all, keep a Vec instead of the single Option. A name that does not exist stops the batch before anything is sent:

Terminal window
cargo run -q -- run slow1 nope
hit: no saved request named "nope"

This step’s end state is tagged cli-ch10 in the examples repository — the project exactly as this article leaves it:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/cli-tool
git checkout cli-ch10

If your terminal shows something different from what this article describes, checking out the tag and diffing it against your own tree shows which line drifted.

hit now runs whole batches at once, but nothing proves any of it keeps working after today. Next the tool gets its first tests: assert_cmd drives the binary the way a user would, and wiremock stands in for the network so the tests run offline, including in CI.

← Back to the track overview