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::spawndemands that the future’s output beSend + 'static.- Standard errors (
std::io::Error,reqwest::Error) and string literals do not implementSend. Box<dyn Error>cannot automatically coerce toBox<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
Sendguarantees, eliminating accidental data races. - Without enforcing
Send, a task could close over non‑Sendtypes 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
Sendvia#[derive(Debug)]andenum Error { Io(std::io::Error), Reqwest(reqwest::Error), Custom(&'static str) }. - Implement
Fromfor each source error:impl From for Error { ... } impl From for Error { ... } - Return
Result<T, Error>from async functions; the enum automatically satisfiesSend. - Use the
thiserrorcrate 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
Boxpain. - 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 implicitSendconversion. - They overlook that
?requiresFrominto the exact return type (Box<dyn Error + Send>). - They may mistakenly trust that
String::into()gives them aBox<dyn Error>without theSendbound. - Debugging such errors requires understanding of trait objects, lifetimes, and the
Sendmarker.
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.