Academy
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.
5 menit baca
Di halaman ini
- The Basics: ? Works with .await
- Spawned Task Errors: JoinError
- Timeout: Adding a Clock to Any Operation
- Retry Patterns: Recovering from Transient Failures
- Error Type Strategy: anyhow vs thiserror
- Broader Implications: Error Strategies, Not Just Error Handling
- Practical Applications & Strategic Takeaways
- Our Commitment to Open Knowledge
- Support Our Mission & Get Your Handbook
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
Artikel berikutnya
The Async Decision Framework: When to Use async, Threads, Rayon, or All Three