Error Handling in Async Rust: Making Failures Graceful in Non-Blocking Code
Master async error handling in Rust! Learn how ? works with .await, handle task joins, add timeouts, build retries, and choose anyhow vs thiserror.
Error Handling in Async Rust: When ? Meets .await (And Why Async Errors Need a Strategy, Not Just a catch_all)
Error handling in synchronous Rust is elegant: Result<T, E>, the ? operator, and explicit error propagation that the compiler enforces. You can't ignore an error without deliberately choosing to. The type system tells you what can fail and how. It's one of Rust's greatest strengths.
Then you add async to the mix, and things get... interesting. Not harder, exactly, but there are new dimensions to consider. Tasks can be spawned independently—what happens when a spawned task fails? Futures can be joined or selected—how do you handle mixed success and failure? Timeouts add a new failure mode that doesn't exist in sync code. And the error types from different async operations often don't unify cleanly, leading to either Box<dyn Error> everywhere or an explosion of custom error types.
None of these problems are unsolvable. But they require strategy, not just tactics. You need a plan for how errors flow through your async pipeline—from the point where they occur to the point where they're handled or reported. At RantAI, where our AI pipelines chain dozens of async operations with different failure modes, that strategy is the difference between "errors are logged and recovered" and "errors cascade and the system falls over." This article, drawn from Chapter 6, Section 6.8 of our guide "The Rust Programming Language," shows the patterns that work.
The Basics: ? Works with .await
use std::io;
async fn read_config(path: &str) -> Result<String, io::Error> {
let contents = tokio::fs::read_to_string(path).await?; // ? works!
Ok(contents)
}
async fn process_config() -> Result<(), Box<dyn std::error::Error>> {
let config = read_config("config.toml").await?;
let port: u16 = config.lines()
.find(|l| l.starts_with("port"))
.ok_or("Missing port")?
.split('=')
.last()
.ok_or("Invalid format")?
.trim()
.parse()?;
println!("Port: {}", port);
Ok(())
}
The ? operator works seamlessly with .await: tokio::fs::read_to_string(path).await? awaits the future and then propagates the error if it's Err. The syntax is natural—it reads left-to-right: "await this operation, then propagate the error."
Spawned Task Errors: JoinError
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
if rand::random::<bool>() {
Ok("success".to_string())
} else {
Err("something went wrong")
}
});
match handle.await {
Ok(Ok(value)) => println!("Task succeeded: {}", value),
Ok(Err(task_error)) => println!("Task returned error: {}", task_error),
Err(join_error) => println!("Task panicked: {}", join_error),
}
}
Spawned tasks introduce two error layers: the JoinError (task panicked or was cancelled) and the task's own Result. The double Ok(Ok(...)) pattern is initially surprising but logically correct—the join can fail and the task can fail, independently.
Timeout: Adding a Clock to Any Operation
use tokio::time::{timeout, Duration};
#[tokio::main]
async fn main() {
match timeout(Duration::from_secs(5), slow_api_call()).await {
Ok(Ok(data)) => println!("Got data: {}", data),
Ok(Err(api_error)) => println!("API error: {}", api_error),
Err(_timeout) => println!("Request timed out after 5 seconds"),
}
}
async fn slow_api_call() -> Result<String, String> {
tokio::time::sleep(Duration::from_secs(10)).await;
Ok("data".to_string())
}
timeout wraps any future and cancels it (by dropping) if it doesn't complete within the specified duration. This adds a third error dimension: the operation can succeed, fail with its own error, or timeout. The pattern timeout → Ok(inner_result) | Err(elapsed) handles all three.
Retry Patterns: Recovering from Transient Failures
async fn fetch_with_retry(url: &str, max_retries: u32) -> Result<String, String> {
let mut last_error = String::new();
for attempt in 1..=max_retries {
match make_request(url).await {
Ok(response) => return Ok(response),
Err(e) => {
last_error = format!("Attempt {}: {}", attempt, e);
println!("{}", last_error);
if attempt < max_retries {
let delay = Duration::from_millis(100 * 2u64.pow(attempt - 1));
tokio::time::sleep(delay).await; // Exponential backoff
}
}
}
}
Err(format!("All {} attempts failed. Last: {}", max_retries, last_error))
}
async fn make_request(_url: &str) -> Result<String, String> {
// Simulate flaky network
if rand::random::<f32>() > 0.5 {
Ok("response data".to_string())
} else {
Err("connection refused".to_string())
}
}
Retry with exponential backoff is the standard pattern for transient failures (network timeouts, temporary service unavailability). The key: only retry transient errors. A 404 or authentication failure should fail immediately—retrying won't help.
Error Type Strategy: anyhow vs thiserror
For applications, anyhow provides ergonomic error handling without custom types:
use anyhow::{Context, Result};
async fn load_config() -> Result<Config> {
let text = tokio::fs::read_to_string("config.toml")
.await
.context("Failed to read config file")?;
let config: Config = toml::from_str(&text)
.context("Failed to parse config")?;
Ok(config)
}
For libraries, thiserror generates custom error types with proper trait implementations:
use thiserror::Error;
#[derive(Error, Debug)]
enum PipelineError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Timeout after {0:?}")]
Timeout(Duration),
#[error("Processing failed: {0}")]
Processing(String),
}
Rule of thumb at RantAI: anyhow for binaries and applications. thiserror for libraries. This gives application code maximum ergonomics and library code maximum information for callers.
Broader Implications: Error Strategies, Not Just Error Handling
At RantAI, every async pipeline has an explicit error strategy: which errors are retried (transient network failures), which are propagated (configuration errors), which are logged and skipped (malformed input records), and which are fatal (database connection loss). The strategy is documented, encoded in types, and tested. The worst async error handling is no strategy at all—where every error gets unwrap()'d or println!()'d and ignored.
Practical Applications & Strategic Takeaways
For newcomers: Use anyhow::Result and .context() for application code. It makes error messages actionable without custom error types.
For library authors: Use thiserror to create meaningful error enums. Your callers need to match on error variants to decide how to handle them.
For architects: Define error strategies per pipeline stage: retry transient, propagate permanent, skip malformed, alert on fatal. Encode the strategy in the error types and handling code.
Our Commitment to Open Knowledge
RantAI is committed to open education. Async error handling is covered in Chapter 6, Section 6.8 of our guide, "The Rust Programming Language," freely available online.
Explore these concepts further: https://trpl.rantai.dev
Support Our Mission & Get Your Handbook
Get the Handbook on Amazon KDP: https://www.amazon.com/dp/B0DHCMD3F2
Get the Handbook on Google Play Books: https://play.google.com/store/books/details?id=INwfEQAAQBAJ
What's your async error strategy? anyhow everywhere, custom errors, or a mix? Share your approach!
#RustLang #ErrorHandling #AsyncRust #Tokio #RantAI #LearnRust #ResultType #Retry #Timeout #SoftwareEngineering
Want to learn more?
Connect with our team to discuss how AI can transform your enterprise.