rust: How to propagate errors across task boundaries in an ergonomic way?

Summary

In async Rust programs, propagating errors across task boundaries (e.g., via tokio::spawn) requires Send error types. Converting from standard error implementations or plain strings to Box<dyn std::error::Error + Send> can be tedious, but the solution is simple: use the ? operator with From implementations and the Into conversion macro Box::<dyn std::error::Error + Send>::from.

Key takeaway: Wrap non‑Send errors in Send + Sync wrappers or custom error enums to enable ergonomically passing them between async tasks.

Root Cause

  • tokio::spawn demands that the future’s output be Send + 'static.
  • Standard errors (std::io::Error, reqwest::Error) and string literals do not implement Send.
  • Box<dyn Error> cannot automatically coerce to Box<dyn Error + Send>, leading to compile‑time mismatches.

Why This Happens in Real Systems

  • Asynchronous runtimes often spawn many lightweight tasks on thread‑pool threads.
  • Error propagation across these tasks must be thread‑safe.
  • Rust’s type system forces explicit Send guarantees, eliminating accidental data races.
  • Without enforcing Send, a task could close over non‑Send types and break safety guarantees.

Real-World Impact

  • Compilation errors for straightforward ?‑based error handling.
  • Boilerplate wrappers that clutter codebases.
  • Developers may mistakenly hide errors behind Box<dyn Any + Send>, giving up type safety.

Example or Code (if necessary and relevant)

use std::error::Error;
use std::fmt;
use tokio::task;

// A small helper to convert any error into a Send‑able Boxed Error
fn into_send_err(e: E) -> Box
where
    E: Error + Send + 'static,
{
    Box::new(e)
}

async fn foo() -> Result<(), Box> {
    let text = reqwest::get("https://example.com/")
        .await
        .map_err(into_send_err)?
        .text()
        .await
        .map_err(into_send_err)?;
    println!("{}", text);
    Err(into_send_err(String::from("example")))
}

#[tokio::main]
async fn main() {
    let handle = task::spawn(async {
        foo().await
    });

    match handle.await {
        Ok(Err(e)) => eprintln!("Task failed: {}", e),
        Ok(Ok(())) => println!("Success!"),
        Err(e) => eprintln!("Join error: {}", e),
    }
}

How Senior Engineers Fix It

  • Create a unified error enum that includes all error types and derives Send via #[derive(Debug)] and enum Error { Io(std::io::Error), Reqwest(reqwest::Error), Custom(&'static str) }.
  • Implement From for each source error:
    impl From for Error { ... }
    impl From for Error { ... }
  • Return Result<T, Error> from async functions; the enum automatically satisfies Send.
  • Use the thiserror crate to reduce boilerplate:
    #[derive(thiserror::Error, Debug)]
    enum Error {
        #[error(transparent)]
        Io(#[from] std::io::Error),
        #[error(transparent)]
        Reqwest(#[from] reqwest::Error),
        #[error("{0}")]
        Custom(&'static str),
    }

Benefits

  • Ergonomic: single error type, no Box pain.
  • Type‑safe: compiler ensures all variants are handled.
  • Extensible: add more variants without altering function signatures.

Why Juniors Miss It

  • They often use anonymous Box<dyn Error> expecting implicit Send conversion.
  • They overlook that ? requires From into the exact return type (Box<dyn Error + Send>).
  • They may mistakenly trust that String::into() gives them a Box<dyn Error> without the Send bound.
  • Debugging such errors requires understanding of trait objects, lifetimes, and the Send marker.

Bottom line: Introducing a small, explicitly Send‑able error type (or using a helper like into_send_err) resolves the issue cleanly and keeps async code maintainable.

Leave a Comment