Guide contents

Lesson 36 of 44

Error Handling in Async Code

An async function returns errors the same way a normal function does: as a Result. What changes in async code is where errors can appear. A spawned task reports its outcome through a JoinHandle, a channel carries errors between tasks, a timeout adds a new failure kind, and a panic is a separate channel of failure. This lesson covers each path and ends with a retry helper that uses them.

Topic Tools
Error types thiserror, anyhow, Box<dyn Error + Send + Sync>
Errors and tasks JoinHandle, JoinError, ? inside spawned tasks
Errors between tasks mpsc of Result, oneshot replies
Time timeout, Elapsed
Recovery classification, backoff, jitter, retry loop

The examples use thiserror 2 and anyhow 1. Add them with cargo add thiserror anyhow.

Example 1: ? in an async function

? works in an async fn exactly as in a synchronous one. It returns early from the function's future with the error, converted through From.

Rust
use std::num::ParseIntError;

async fn parse_port(text: &str) -> Result<u16, ParseIntError> {
    tokio::time::sleep(std::time::Duration::from_millis(1)).await;
    let port: u16 = text.trim().parse()?;
    Ok(port)
}

#[tokio::main]
async fn main() {
    println!("{:?}", parse_port(" 8080 ").await);
    println!("{:?}", parse_port("http").await);
}
Output
Ok(8080)
Err(ParseIntError { kind: InvalidDigit })

The function does nothing until it is awaited. The ? runs after the sleep completes, so an error appears only when the caller awaits the future.

Example 2: An error enum with thiserror

thiserror generates Display, Error, and From implementations from attributes. #[error("...")] sets the message, #[from] adds a From impl and marks the field as the error's source.

Rust
use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum ConfigError {
    #[error("port is not a number")]
    BadPort(#[from] ParseIntError),
    #[error("port {0} is reserved")]
    Reserved(u16),
}

async fn load_port(text: &str) -> Result<u16, ConfigError> {
    let port: u16 = text.parse()?;
    if port < 1024 {
        return Err(ConfigError::Reserved(port));
    }
    Ok(port)
}

#[tokio::main]
async fn main() {
    for text in ["8080", "80", "eighty"] {
        match load_port(text).await {
            Ok(port) => println!("ok {port}"),
            Err(error) => {
                use std::error::Error;
                println!("error: {error} (source: {:?})", error.source().map(|s| s.to_string()));
            }
        }
    }
}
Output
ok 8080
error: port 80 is reserved (source: None)
error: port is not a number (source: Some("invalid digit found in string"))

? converted the ParseIntError into ConfigError::BadPort through the generated From impl. The source method exposes the inner error, which is how an error chain is walked (Example 11).

Example 3: Box<dyn Error + Send + Sync>

A boxed trait object holds any error type. ? converts any E: Error + Send + Sync + 'static into it, and string slices convert through .into().

Rust
use std::error::Error;

type BoxError = Box<dyn Error + Send + Sync>;

async fn step(n: i32) -> Result<i32, BoxError> {
    if n == 0 {
        return Err("zero is not allowed".into());
    }
    let parsed: i32 = "42".parse()?;
    Ok(parsed / n)
}

#[tokio::main]
async fn main() {
    println!("{:?}", step(2).await);
    println!("{:?}", step(0).await);
    let handle = tokio::spawn(step(6));
    println!("{:?}", handle.await.unwrap());
}
Output
Ok(21)
Err("zero is not allowed")
Ok(7)

Send + Sync is part of the type on purpose. A spawned task must be Send (movable between threads, see Spawning Tasks), and a future is Send only if every value alive across an .await is Send. Sync means shared references to the value are safe across threads. The next example shows what happens without the bounds.

Example 4: Why the Send bound is required

A Box<dyn Error> without Send is not Send. Holding one across an .await makes the whole future non-Send, and tokio::spawn rejects it.

Rust✗ does not compile
use std::error::Error;

async fn fallible() -> Result<(), Box<dyn Error>> {
    Err("boom".into())
}

async fn work() {
    let result = fallible().await;
    tokio::task::yield_now().await;
    println!("{:?}", result.is_err());
}

#[tokio::main]
async fn main() {
    tokio::spawn(work()).await.unwrap();
}
Output
error: future cannot be sent between threads safely
   --> src/main.rs:15:18
    |
 15 |     tokio::spawn(work()).await.unwrap();
    |                  ^^^^^^ future returned by `work` is not `Send`
    |
    = help: the trait `Send` is not implemented for `dyn std::error::Error`
note: future is not `Send` as this value is used across an await
   --> src/main.rs:9:30
    |
  8 |     let result = fallible().await;
    |         ------ has type `Result<(), Box<dyn std::error::Error>>` which is not `Send`
  9 |     tokio::task::yield_now().await;
    |                              ^^^^^ await occurs here, with `result` maybe used later
note: required by a bound in `tokio::spawn`
   --> ~/.cargo/registry/src/.../tokio-1.53.1/src/task/spawn.rs:176:21
    |
174 |     pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
    |            ----- required by a bound in this function
175 |     where
176 |         F: Future + Send + 'static,
    |                     ^^^^ required by this bound in `spawn`

The fix is to write Box<dyn Error + Send + Sync> in the return type, or to use anyhow::Error, which is always Send + Sync.

Example 5: Errors from a JoinHandle

Awaiting a JoinHandle<T> returns Result<T, JoinError>. When the task itself returns a Result, the value is a Result inside a Result: the outer layer says whether the task ran to completion, the inner layer is the task's own result.

Rust
use thiserror::Error;
use tokio::task::JoinError;

#[derive(Debug, Error)]
enum AppError {
    #[error("bad number: {0}")]
    Parse(#[from] std::num::ParseIntError),
    #[error("task did not finish: {0}")]
    Join(#[from] JoinError),
}

async fn parse_in_task(text: &'static str) -> Result<i32, AppError> {
    let handle = tokio::spawn(async move { text.parse::<i32>() });
    // First `?` unwraps the JoinError layer, second `?` the ParseIntError layer.
    let value = handle.await??;
    Ok(value)
}

#[tokio::main]
async fn main() {
    println!("{:?}", parse_in_task("17").await);
    println!("{:?}", parse_in_task("x17").await);
}
Output
Ok(17)
Err(Parse(ParseIntError { kind: InvalidDigit }))

A From<JoinError> impl on the application error type flattens the two layers with ??.

Example 6: ? inside a spawned task

An async block has no declared return type, so ? inside it cannot infer the error type. Annotate the block's final value with Ok::<T, E>(...).

Rust
use std::error::Error;

type BoxError = Box<dyn Error + Send + Sync>;

async fn read_number(text: &str) -> Result<u32, BoxError> {
    Ok(text.parse()?)
}

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        let a = read_number("10").await?;
        let b = read_number("x").await?;
        Ok::<u32, BoxError>(a + b)
    });

    match handle.await {
        Ok(Ok(sum)) => println!("sum = {sum}"),
        Ok(Err(error)) => println!("task returned an error: {error}"),
        Err(error) => println!("task failed to finish: {error}"),
    }
}
Output
task returned an error: invalid digit found in string

Without the Ok::<u32, BoxError> annotation the compiler reports that it cannot infer the error type of the block. A named async fn avoids the annotation because its signature fixes the type.

Example 7: A panic is not an error

A panic inside a task ends that task only. The panic does not become the task's Err; it appears on the handle as a JoinError. Its is_panic and is_cancelled methods tell the two failure kinds apart (Cancellation, abort, and Panics).

Rust
#[tokio::main]
async fn main() {
    let failing = tokio::spawn(async { Err::<(), &str>("a normal error") });
    let panicking = tokio::spawn(async {
        if true {
            panic!("a panic");
        }
        Ok::<(), &str>(())
    });
    let aborted = tokio::spawn(std::future::pending::<()>());
    aborted.abort();

    println!("failing:   {:?}", failing.await);

    let error = panicking.await.unwrap_err();
    println!("panicking: is_panic={} is_cancelled={}", error.is_panic(), error.is_cancelled());

    let error = aborted.await.unwrap_err();
    println!("aborted:   is_panic={} is_cancelled={}", error.is_panic(), error.is_cancelled());
}
Output
failing:   Ok(Err("a normal error"))
panicking: is_panic=true is_cancelled=false
aborted:   is_panic=false is_cancelled=true

The panic message itself goes to standard error through the panic hook, so it does not appear above. The rule for choosing: expected failures (bad input, a refused connection, a timeout) are Err values that callers can match on. A panic marks a broken invariant, a bug in the program. A service that supervises tasks treats is_panic as "this task is dead and its state may be inconsistent".

Example 8: Collecting errors from many tasks

Spawn every task first, then await the handles. Awaiting in order keeps results in spawn order, and one failed task does not stop the others from running.

Rust
use std::error::Error;

type BoxError = Box<dyn Error + Send + Sync>;

async fn check(id: u32) -> Result<u32, BoxError> {
    if id % 3 == 0 {
        Err(format!("job {id} failed").into())
    } else {
        Ok(id * 10)
    }
}

#[tokio::main]
async fn main() {
    let handles: Vec<_> = (1..=6).map(|id| tokio::spawn(check(id))).collect();

    let mut successes = Vec::new();
    let mut failures = Vec::new();
    for handle in handles {
        match handle.await {
            Ok(Ok(value)) => successes.push(value),
            Ok(Err(error)) => failures.push(error.to_string()),
            Err(join_error) => failures.push(format!("join: {join_error}")),
        }
    }
    println!("successes: {successes:?}");
    println!("failures:  {failures:?}");
}
Output
successes: [10, 20, 40, 50]
failures:  ["job 3 failed", "job 6 failed"]

This is the "collect all errors" policy. The alternative, "fail fast", stops at the first error and cancels the rest; JoinSet and Structured Concurrency covers both policies with JoinSet, and join!, try_join!, and Running Work Concurrently covers try_join!.

Example 9: Errors over channels

A channel of Result<T, E> carries failures from a producer task to a consumer. The producer keeps going after an error unless it chooses to stop, and the consumer decides what to do with each Err.

Rust
use tokio::sync::mpsc;

#[derive(Debug)]
enum ReadError {
    Corrupt(u32),
}

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<Result<u32, ReadError>>(4);

    tokio::spawn(async move {
        for record in 1..=5 {
            let item = if record == 3 { Err(ReadError::Corrupt(record)) } else { Ok(record * 100) };
            if tx.send(item).await.is_err() {
                break;
            }
        }
    });

    let mut total = 0;
    while let Some(item) = rx.recv().await {
        match item {
            Ok(value) => total += value,
            Err(error) => println!("skipping: {error:?}"),
        }
    }
    println!("total = {total}");
}
Output
skipping: Corrupt(3)
total = 1200

Two kinds of failure exist on a channel. An Err inside the item is an application error that the producer sends on purpose. A SendError from tx.send (or None from recv) is a channel error: the other side is gone (mpsc Channels).

Example 10: Request and reply with oneshot

A request message carries a oneshot::Sender<Result<...>>. The worker answers on it, and the caller awaits the reply. The caller sees two failure kinds: an error reply, and a RecvError when the worker dropped the sender without answering (oneshot, broadcast, and watch).

Rust
use tokio::sync::{mpsc, oneshot};

struct Request {
    divisor: i32,
    reply: oneshot::Sender<Result<i32, String>>,
}

async fn call(tx: &mpsc::Sender<Request>, divisor: i32) -> Result<i32, String> {
    let (reply, response) = oneshot::channel();
    tx.send(Request { divisor, reply }).await.map_err(|_| "worker is gone".to_string())?;
    // `RecvError` means the worker dropped `reply` without sending.
    response.await.map_err(|_| "worker dropped the request".to_string())?
}

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<Request>(8);

    let worker = tokio::spawn(async move {
        while let Some(request) = rx.recv().await {
            match request.divisor {
                -1 => drop(request.reply), // never answers
                0 => {
                    let _ = request.reply.send(Err("division by zero".to_string()));
                }
                d => {
                    let _ = request.reply.send(Ok(100 / d));
                }
            }
        }
    });

    println!("{:?}", call(&tx, 5).await);
    println!("{:?}", call(&tx, 0).await);
    println!("{:?}", call(&tx, -1).await);

    drop(tx);
    worker.await.unwrap();
}
Output
Ok(20)
Err("division by zero")
Err("worker dropped the request")

The trailing ? on response.await.map_err(...) handles the outer Result, and the value that remains is the reply's own Result<i32, String>, which the function returns directly.

Example 11: Timeouts as errors

tokio::time::timeout(duration, future) returns Err(Elapsed) when the deadline passes first. The future is dropped at that moment, which cancels it. The example runs on the paused clock so the result does not depend on real time (Time).

Rust
use std::time::Duration;
use thiserror::Error;
use tokio::time::{sleep, timeout};

#[derive(Debug, Error)]
enum FetchError {
    #[error("timed out after {0:?}")]
    Timeout(Duration),
    #[error("backend said no")]
    Backend,
}

async fn backend(delay_ms: u64, ok: bool) -> Result<&'static str, FetchError> {
    sleep(Duration::from_millis(delay_ms)).await;
    if ok { Ok("payload") } else { Err(FetchError::Backend) }
}

async fn fetch(delay_ms: u64, ok: bool) -> Result<&'static str, FetchError> {
    let limit = Duration::from_millis(100);
    match timeout(limit, backend(delay_ms, ok)).await {
        Ok(inner) => inner,
        Err(_elapsed) => Err(FetchError::Timeout(limit)),
    }
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    println!("{:?}", fetch(50, true).await);
    println!("{:?}", fetch(50, false).await);
    println!("{}", fetch(500, true).await.unwrap_err());
}
Output
Ok("payload")
Err(Backend)
timed out after 100ms

timeout returns a nested Result too: the outer Result<_, Elapsed> for the deadline and the inner one for the operation. The match above merges them into one application error type. A From<Elapsed> impl also works when the error does not need to record the limit.

Example 12: Context and error chains

An error message such as "invalid digit" does not say which value or which step failed. Wrapping an error adds context and keeps the original as its source. The chain is walked by calling source until it returns None.

Rust
use std::error::Error;
use thiserror::Error;

#[derive(Debug, Error)]
#[error("could not load {name}")]
struct LoadError {
    name: &'static str,
    #[source]
    cause: std::num::ParseIntError,
}

async fn load(name: &'static str, text: &str) -> Result<i64, LoadError> {
    text.parse().map_err(|cause| LoadError { name, cause })
}

fn print_chain(error: &dyn Error) {
    let mut depth = 0;
    let mut current = Some(error);
    while let Some(e) = current {
        println!("{}{}", "  ".repeat(depth), e);
        current = e.source();
        depth += 1;
    }
}

#[tokio::main]
async fn main() {
    let error = load("retry_limit", "three").await.unwrap_err();
    print_chain(&error);
}
Output
could not load retry_limit
  invalid digit found in string

Each Display implementation should describe only its own layer. A logger that prints the whole chain then shows each layer once, from the outside in.

Example 13: anyhow for applications

anyhow::Error is a boxed error that is Send + Sync + 'static and can hold a stack of context messages. anyhow::Result<T> is Result<T, anyhow::Error>, and .context(...) or .with_context(|| ...) (the closure runs only on error) adds a layer.

Rust
use anyhow::{Context, Result};

async fn read_setting(key: &str, raw: &str) -> Result<u32> {
    let value: u32 = raw
        .parse()
        .with_context(|| format!("setting `{key}` has invalid value {raw:?}"))?;
    Ok(value)
}

async fn start(raw_port: &str) -> Result<u32> {
    let port = read_setting("port", raw_port).await.context("service failed to start")?;
    Ok(port)
}

#[tokio::main]
async fn main() {
    println!("{:?}", start("8080").await.ok());

    let error = start("http").await.unwrap_err();
    println!("display:   {error}");
    println!("alternate: {error:#}");
    println!("layers:");
    for (index, layer) in error.chain().enumerate() {
        println!("  {index}: {layer}");
    }
}
Output
Some(8080)
display:   service failed to start
alternate: service failed to start: setting `port` has invalid value "http": invalid digit found in string
layers:
  0: service failed to start
  1: setting `port` has invalid value "http"
  2: invalid digit found in string

{error} prints only the outermost message. {error:#} prints the whole chain on one line, and {error:?} prints a multi-line "Caused by" report. chain() iterates the layers.

Example 14: Matching on an anyhow error

An anyhow::Error hides its concrete type. downcast_ref::<T>() gets it back, which lets a caller branch on one specific kind of failure without giving up anyhow for the rest.

Rust
use anyhow::{Result, anyhow, bail};
use thiserror::Error;

#[derive(Debug, Error)]
#[error("rate limited, retry in {retry_after_secs}s")]
struct RateLimited {
    retry_after_secs: u64,
}

async fn call(attempt: u32) -> Result<&'static str> {
    match attempt {
        0 => Err(RateLimited { retry_after_secs: 30 }.into()),
        1 => bail!("connection refused"),
        _ => Ok("done"),
    }
}

#[tokio::main]
async fn main() {
    for attempt in 0..3 {
        match call(attempt).await {
            Ok(value) => println!("attempt {attempt}: {value}"),
            Err(error) => match error.downcast_ref::<RateLimited>() {
                Some(limited) => println!("attempt {attempt}: wait {}s", limited.retry_after_secs),
                None => println!("attempt {attempt}: other error: {error}"),
            },
        }
    }
    let built = anyhow!("built from a {}", "format string");
    println!("{built}");
}
Output
attempt 0: wait 30s
attempt 1: other error: connection refused
attempt 2: done
built from a format string

Example 15: Choosing between the error tools

The two crates solve different problems, so the choice follows from who consumes the error.

Situation Use Reason
A library or a module with a public API an enum with thiserror callers can match on variants; the variants are part of the API
An application's main and glue code anyhow::Result no enum to maintain; context messages are cheap to add
A quick prototype or a type alias for tasks Box<dyn Error + Send + Sync> no extra dependency
A boundary between layers that must retry a typed enum with an is_retryable method the retry loop needs to inspect the error

Both styles combine: a library returns its thiserror enum, and the application wraps it in anyhow with context.

Example 16: Retryable and fatal errors

A retry loop must retry only errors that can succeed later. A timeout or a refused connection is worth another attempt. A rejected credential or a malformed request fails the same way every time. Encoding the split in the error type keeps the loop generic.

Rust
use thiserror::Error;

#[derive(Debug, Error, PartialEq)]
enum ApiError {
    #[error("timed out")]
    Timeout,
    #[error("server busy (status {0})")]
    Unavailable(u16),
    #[error("invalid credentials")]
    Unauthorized,
    #[error("bad request: {0}")]
    BadRequest(String),
}

impl ApiError {
    fn is_retryable(&self) -> bool {
        match self {
            ApiError::Timeout => true,
            ApiError::Unavailable(status) => matches!(status, 429 | 502 | 503 | 504),
            ApiError::Unauthorized | ApiError::BadRequest(_) => false,
        }
    }
}

#[tokio::main]
async fn main() {
    let errors = [
        ApiError::Timeout,
        ApiError::Unavailable(503),
        ApiError::Unavailable(500),
        ApiError::Unauthorized,
        ApiError::BadRequest("missing field".into()),
    ];
    for error in &errors {
        println!("{:<28} retryable={}", error.to_string(), error.is_retryable());
    }
}
Output
timed out                    retryable=true
server busy (status 503)     retryable=true
server busy (status 500)     retryable=false
invalid credentials          retryable=false
bad request: missing field   retryable=false

A retry also requires the operation to be idempotent, meaning that running it twice has the same effect as running it once. Retrying a non-idempotent request such as "charge this card" after a timeout can repeat the side effect, because the first attempt may have succeeded before the timeout.

Example 17: Exponential backoff

Backoff is a delay between attempts that grows after each failure, so a struggling service is not hit again immediately. Exponential backoff doubles the delay each time, up to a cap.

Rust
use std::time::Duration;

struct Backoff {
    base: Duration,
    cap: Duration,
}

impl Backoff {
    /// Delay before retry number `retry` (0 is the first retry).
    fn delay(&self, retry: u32) -> Duration {
        let factor = 2u32.saturating_pow(retry);
        self.base.saturating_mul(factor).min(self.cap)
    }
}

fn main() {
    let backoff = Backoff { base: Duration::from_millis(100), cap: Duration::from_secs(2) };
    for retry in 0..7 {
        println!("retry {retry}: wait {:?}", backoff.delay(retry));
    }
}
Output
retry 0: wait 100ms
retry 1: wait 200ms
retry 2: wait 400ms
retry 3: wait 800ms
retry 4: wait 1.6s
retry 5: wait 2s
retry 6: wait 2s

saturating_pow and saturating_mul prevent overflow for large retry counts, and min(cap) bounds the wait. Without a cap the delay for the 20th retry would be over a day.

Example 18: Jitter

When many clients fail at the same moment, identical backoff schedules make them all retry at the same moments, which produces repeated load spikes. Jitter randomizes each delay. "Full jitter" picks a uniform value between zero and the backoff delay.

The example uses a small deterministic generator (xorshift) so the printed numbers are reproducible. Production code takes its randomness from the rand crate.

Rust
use std::time::Duration;

struct XorShift(u64);

impl XorShift {
    fn next(&mut self) -> u64 {
        self.0 ^= self.0 << 13;
        self.0 ^= self.0 >> 7;
        self.0 ^= self.0 << 17;
        self.0
    }
}

fn full_jitter(delay: Duration, rng: &mut XorShift) -> Duration {
    let millis = delay.as_millis() as u64;
    Duration::from_millis(rng.next() % (millis + 1))
}

fn main() {
    let mut client_a = XorShift(0x9E3779B97F4A7C15);
    let mut client_b = XorShift(0xD1B54A32D192ED03);
    let mut all_within_bound = true;

    for retry in 0..5u32 {
        let backoff = Duration::from_millis(100 * 2u64.pow(retry));
        let a = full_jitter(backoff, &mut client_a);
        let b = full_jitter(backoff, &mut client_b);
        all_within_bound &= a <= backoff && b <= backoff;
        println!("retry {retry}: bound {backoff:?}, clients differ: {}", a != b);
    }
    println!("all delays within bound: {all_within_bound}");
}
Output
retry 0: bound 100ms, clients differ: true
retry 1: bound 200ms, clients differ: true
retry 2: bound 400ms, clients differ: true
retry 3: bound 800ms, clients differ: true
retry 4: bound 1.6s, clients differ: true
all delays within bound: true

Two clients with the same backoff bound now wait different amounts. The average wait halves compared with plain backoff, and the retries spread over the whole window.

Example 19: A generic retry function

The pieces combine into one function. It takes a closure that starts an attempt, a trait for the retryable check, and a policy. The loop returns fatal errors at once, retries transient ones with a delay, and returns the last error when attempts run out. The example runs on the paused clock, so the sleeps take no real time.

Rust
use std::future::Future;
use std::time::Duration;
use thiserror::Error;

#[derive(Debug, Error, PartialEq)]
enum ApiError {
    #[error("timed out")]
    Timeout,
    #[error("invalid credentials")]
    Unauthorized,
}

trait Retryable {
    fn is_retryable(&self) -> bool;
}

impl Retryable for ApiError {
    fn is_retryable(&self) -> bool {
        matches!(self, ApiError::Timeout)
    }
}

struct Policy {
    max_attempts: u32,
    base: Duration,
    cap: Duration,
}

async fn retry<T, E, F, Fut>(policy: &Policy, mut attempt: F) -> Result<T, E>
where
    E: Retryable,
    F: FnMut(u32) -> Fut,
    Fut: Future<Output = Result<T, E>>,
{
    let mut number = 1;
    loop {
        match attempt(number).await {
            Ok(value) => return Ok(value),
            Err(error) if !error.is_retryable() || number >= policy.max_attempts => return Err(error),
            Err(_) => {
                let delay = policy.base.saturating_mul(2u32.saturating_pow(number - 1)).min(policy.cap);
                println!("attempt {number} failed, waiting {delay:?}");
                tokio::time::sleep(delay).await;
                number += 1;
            }
        }
    }
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let policy = Policy { max_attempts: 4, base: Duration::from_millis(100), cap: Duration::from_secs(1) };

    // Succeeds on the third attempt.
    let result = retry(&policy, |n| async move { if n < 3 { Err(ApiError::Timeout) } else { Ok(n) } }).await;
    println!("first call:  {result:?}");

    // A fatal error is returned immediately.
    let result = retry(&policy, |_| async { Err::<u32, _>(ApiError::Unauthorized) }).await;
    println!("second call: {result:?}");

    // Every attempt fails: the last error is returned.
    let result = retry(&policy, |_| async { Err::<u32, _>(ApiError::Timeout) }).await;
    println!("third call:  {result:?}");
}
Output
attempt 1 failed, waiting 100ms
attempt 2 failed, waiting 200ms
first call:  Ok(3)
second call: Err(Unauthorized)
attempt 1 failed, waiting 100ms
attempt 2 failed, waiting 200ms
attempt 3 failed, waiting 400ms
third call:  Err(Timeout)

A production policy adds jitter to delay, wraps each attempt in timeout so a hung attempt counts as a failure, and often bounds the total time as well as the attempt count. Backpressure and Load Shedding covers timeouts as budgets across a whole call chain.

Example 20: Errors from main

main may return Result. On Err, the process exits with status 1 and prints the error with its Debug format to standard error. With anyhow::Result that output includes the chain of causes.

Rustexits with an error
use anyhow::{Context, Result};

#[tokio::main]
async fn main() -> Result<()> {
    println!("starting");
    let port: u16 = "not-a-port".parse().context("reading PORT")?;
    println!("listening on {port}");
    Ok(())
}
Output
starting
Error: reading PORT

Caused by:
    invalid digit found in string

The Error: ... report goes to standard error and the exit status is 1. Cleanup that must run before exit belongs before this return, because the runtime is dropped when main returns.

Interview points

  • ? in an async function returns from the future with the converted error; nothing runs until the future is awaited.
  • JoinHandle<Result<T, E>> awaits to Result<Result<T, E>, JoinError>. A From<JoinError> impl flattens it with ??.
  • A panic in a task becomes JoinError with is_panic(). It is not the task's Err. Aborting gives is_cancelled().
  • Errors held across an .await in a spawned task must be Send; use Box<dyn Error + Send + Sync> or anyhow::Error.
  • An async block needs Ok::<T, E>(...) when it uses ?, because the block has no signature.
  • thiserror fits code whose callers match on variants; anyhow fits application code that adds context and reports.
  • timeout returns Err(Elapsed) and drops (cancels) the inner future. The inner operation may have made partial progress.
  • Retry only errors that can change (timeouts, 429, 503), only idempotent operations, with capped exponential backoff, jitter, and a bound on attempts or total time.
  • Jitter prevents synchronized retries from many clients (a retry storm).
  • A closed channel and a returned Err are different signals: the first means the peer is gone, the second is data.

Summary

  • Async functions return Result like synchronous ones; failures also come through JoinError, channels, and Elapsed.
  • Give each layer of an error its own message and keep the cause as source; print the chain when reporting.
  • Box<dyn Error + Send + Sync> is the smallest error type that works across spawned tasks.
  • anyhow::Error adds context, chain, and downcast_ref for application code.
  • Classify errors as retryable or fatal in the error type, and put the classification behind a method or trait.
  • Backoff doubles the delay up to a cap; full jitter picks a random delay between zero and the backoff value.
  • Panics indicate bugs and are observed through JoinError::is_panic, not through the task's Result.