Introducing `eros`: A Revolution In Error Handling For Rust

189 points InternalServerError7 55 comments

Everyone has weird niches they enjoy most. For me that is error handling. In fact I have already authored a fairly popular error handling library called error_set. I love Rust out of the box error handling, but it is not quiet perfect. I have spent the past few years mulling over and prototyping error handling approaches and I believe I have come up with the best error handling approach for most cases (I know this is a bold claim, but once you see it in action you may agree).

For the past few months I have been working on eros in secret and today I release it to the community. Eros combines the best of libraries like anyhow and terrors with unique approaches to create the most ergonomic yet typed-capable error handling approach to date.

Eros is built on 4 error handling philosophies:

  • Error types only matter when the caller cares about the type, otherwise this just hinders ergonomics and creates unnecessary noise.
  • There should be no boilerplate needed when handling single or multiple typed errors - no need to create another error enum or nest errors.
  • Users should be able to seamlessly transition to and from fully typed errors.
  • Errors should always provided context of the operations in the call stack that lead to the error.

Example (syntax highlighting here):

use eros::{
    bail, Context, FlateUnionResult, IntoConcreteTracedError, IntoDynTracedError, IntoUnionResult,
    TracedError,
};
use reqwest::blocking::{Client, Response};
use std::thread::sleep;
use std::time::Duration;

// Add tracing to an error by wrapping it in a `TracedError`.
// When we don't care about the error type we can use `eros::Result<_>` which has tracing.
// `eros::Result<_>` === `Result<_,TracedError>` === `TracedResult<_>`
// When we *do* care about the error type we can use `eros::Result<_,_>` which also has tracing but preserves the error type.
// `eros::Result<_,_>` === `Result<_,TracedError<_>>` === `TracedResult<_,_>`
// In the below example we don't preserve the error type.
fn handle_response(res: Response) -> eros::Result<String> {
    if !res.status().is_success() {
        // `bail!` to directly bail with the error message.
        // See `traced!` to create a `TracedError` without bailing.
        bail!("Bad response: {}", res.status());
    }

    let body = res
        .text()
        // Trace the `Err` without the type (`TracedError`)
        .traced_dyn()
        // Add context to the traced error if an `Err`
        .context("while reading response body")?;
    Ok(body)
}

// Explicitly handle multiple Err types at the same time with `UnionResult`.
// No new error enum creation is needed or nesting of errors.
// `UnionResult<_,_>` === `Result<_,ErrorUnion<_>>`
fn fetch_url(url: &str) -> eros::UnionResult<String, (TracedError<reqwest::Error>, TracedError)> {
    let client = Client::new();

    let res = client
        .get(url)
        .send()
        // Explicitly trace the `Err` with the type (`TracedError<reqwest::Error>`)
        .traced()
        // Add lazy context to the traced error if an `Err`
        .with_context(|| format!("Url: {url}"))
        // Convert the `TracedError<reqwest::Error>` into a `UnionError<_>`.
        // If this type was already a `UnionError`, we would call `inflate` instead.
        .union()?;

    handle_response(res).union()
}

fn fetch_with_retry(url: &str, retries: usize) -> eros::Result<String> {
    let mut attempts = 0;

    loop {
        attempts += 1;

        // Handle one of the error types explicitly with `deflate`!
        match fetch_url(url).deflate::<TracedError<reqwest::Error>, _>() {
            Ok(request_error) => {
                if attempts < retries {
                    sleep(Duration::from_millis(200));
                    continue;
                } else {
                    return Err(request_error.into_dyn().context("Retries exceeded"));
                }
            }
            // `result` is now `UnionResult<String,(TracedError,)>`, so we convert the `Err` type
            // into `TracedError`. Thus, we now have a `Result<String,TracedError>`.
            Err(result) => return result.map_err(|e| e.into_inner()),
        }
    }
}

fn main() {
    match fetch_with_retry("https://badurl214651523152316hng.com", 3).context("Fetch failed") {
        Ok(body) => println!("Ok Body:
{body}"),
        Err(err) => eprintln!("Error:
{err:?}"),
    }
}

Output:

Error:
error sending request

Context:
        - Url: https://badurl214651523152316hng.com
        - Retries exceeded
        - Fetch failed

Backtrace:
   0: eros::generic_error::TracedError<T>::new
             at ./src/generic_error.rs:47:24
   1: <E as eros::generic_error::IntoConcreteTracedError<eros::generic_error::TracedError<E>>>::traced
             at ./src/generic_error.rs:211:9
   2: <core::result::Result<S,E> as eros::generic_error::IntoConcreteTracedError<core::result::Result<S,eros::generic_error::TracedError<E>>>>::traced::{{closure}}
             at ./src/generic_error.rs:235:28
   3: core::result::Result<T,E>::map_err
             at /usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/result.rs:914:27
   4: <core::result::Result<S,E> as eros::generic_error::IntoConcreteTracedError<core::result::Result<S,eros::generic_error::TracedError<E>>>>::traced
             at ./src/generic_error.rs:235:14
   5: x::fetch_url
             at ./tests/x.rs:39:10
   6: x::fetch_with_retry
             at ./tests/x.rs:56:15
   7: x::main
             at ./tests/x.rs:74:11
   8: x::main::{{closure}}
             at ./tests/x.rs:73:10
<Removed To Shorten Example>

Checkout the github for more info: https://github.com/mcmah309/eros