Skip to main content

6. Errors implementation Standard

· 6 min read

Status

Draft

Context

Error handling is difficult with Rust:

  • Many ways of implementing them with different crates (thiserror, anyhow, ...)
  • No exception like handling of errors
  • No stack trace or context available by default
  • Backtrace uniquely when a panic occurs and if RUST_BACKTRACE environment variable is set to 1 or full

We think the errors handling should be done in a consistent way in the project. Thus we have worked on a standardization of their implementation and tried to apply it to the whole repository. This has enabled us to have a clear vision of the do and don't that we intend to summarize in this ADR.

Decision

Therefore

  • We have decided to use thiserror and anyhow crates to implement the errors:
    • thiserror is used to create module or domain errors that come from our developments and can be easily identified (as they are strongly typed).
    • anyhow is used to add a context to an error triggered by a sub-system. The context is a convenient way to get 'stack trace' like debug information.

Here is a Rust playground that summarizes the usage of thiserror:

#[allow(unused_imports)]
use anyhow::{anyhow, Context, Result}; // 1.0.71
use thiserror::Error; // 1.0.43

#[derive(Error, Debug)]
#[error("Codec error: {msg}")]
pub struct CodecError {
    msg: String,
    #[source] // optional if field name is `source`
    source: anyhow::Error,
}

#[derive(Error, Debug)]
pub enum DomainError {
    #[error("Error with codec: {0:?}")]
    CodecWithOnlyDebug(CodecError),

    #[error("Error with codec")]
    CodecWithSource(#[source] CodecError),

    #[error("Error with codec: {0}")]
    CodecWithoutAnything(CodecError),

    #[error("Anyhow error: {0:?}")]
    AnyhowWrapWithOnlyDebug(anyhow::Error),

    #[error("Anyhow error")]
    AnyhowWrapWithSource(#[source] anyhow::Error),

    #[error("Anyhow error: {0}")]
    AnyhowWrapWithoutAnything(anyhow::Error),
}

fn anyhow_result() -> Result<()> {
    "invalid_number"
        .parse::<u64>()
        .map(|_| ())
        .with_context(|| "Reading database failure")
}

fn thiserror_struct() -> Result<(), CodecError> {
    Err(CodecError {
        msg: "My message".to_string(),
        source: anyhow!("Could not decode config"),
    })?;
    Ok(())
}

fn print_error(title: &str, error: anyhow::Error) {
    println!("{title:-^80}");
    println!("{error:?}\n",);
}

fn main() {
    println!("1 - Printing errors from enum variant that contains a error struct\n");
    // Debug the inner error struct: "normal" debug without the anyhow touch
    print_error(
        "DomainError::CodecWithOnlyDebug",
        anyhow!(DomainError::CodecWithOnlyDebug(
            thiserror_struct().unwrap_err()
        )),
    );
    // marking the inner error struct as source: anyhow will be able to make a
    // stacktrace out of this error. Nice !
    print_error(
        "DomainError::CodecWithSource",
        anyhow!(DomainError::CodecWithSource(
            thiserror_struct().unwrap_err()
        )),
    );
    // without debugging the inner error: only show the error text
    print_error(
        "DomainError::CodecWithoutAnything",
        anyhow!(DomainError::CodecWithoutAnything(
            thiserror_struct().unwrap_err()
        )),
    );

    println!("\n2 - Printing errors from enum variant that contains a anyhow error\n");
    // using only debug: the first two errors of the stack will be merged
    print_error(
        "DomainError::AnyhowWrapWithOnlyDebug",
        anyhow!(DomainError::AnyhowWrapWithOnlyDebug(
            anyhow_result().with_context(|| "context").unwrap_err()
        )),
    );
    // using #[source] attribute: each error of the stack will have a line
    print_error(
        "DomainError::AnyhowWrapWithSource",
        anyhow!(DomainError::AnyhowWrapWithSource(
            anyhow_result().with_context(|| "context").unwrap_err()
        )),
    );
    // without debug nor source: only the uppermost error is print
    print_error(
        "DomainError::AnyhowWrapWithoutAnything",
        anyhow!(DomainError::AnyhowWrapWithoutAnything(
            anyhow_result().with_context(|| "context").unwrap_err()
        )),
    );
}

Which will output errors this way:

1 - Printing errors from enum variant that contains a error struct

------------------------DomainError::CodecWithOnlyDebug-------------------------
Error with codec: CodecError { msg: "My message", source: Could not decode config }

--------------------------DomainError::CodecWithSource--------------------------
Error with codec

Caused by:
    0: Codec error: My message
    1: Could not decode config

-----------------------DomainError::CodecWithoutAnything------------------------
Error with codec: Codec error: My message


2 - Printing errors from enum variant that contains a anyhow error

----------------------DomainError::AnyhowWrapWithOnlyDebug----------------------
Anyhow error: context

Caused by:
    0: Reading database failure
    1: invalid digit found in string

-----------------------DomainError::AnyhowWrapWithSource------------------------
Anyhow error

Caused by:
    0: context
    1: Reading database failure
    2: invalid digit found in string

---------------------DomainError::AnyhowWrapWithoutAnything---------------------
Anyhow error: context

Here is a Rust playground that summarizes the usage of the context feature form anyhow:

#[allow(unused_imports)]
use anyhow::{anyhow, Context, Result}; // 1.0.71

fn read_db() -> Result<()> {
    "invalid_number"
        .parse::<u64>()
        .map(|_| ())
        .with_context(|| "Reading database failure")
}

fn do_work() -> Result<()> {
    read_db().with_context(|| "Important work failed while reading database")
}

fn do_service_work() -> Result<()> {
    do_work().with_context(|| "Service could not do the important work")
}

fn main() {
    let error = do_service_work().unwrap_err();

    println!("Error string:\n {error}\n\n");
    println!("Error debug:\n {error:?}\n\n");
    println!("Error pretty:\n {error:#?}\n\n");
}

Which will output errors this way:

Error string:
 Service could not do the important work


Error debug:
 Service could not do the important work

Caused by:
    0: Important work failed while reading database
    1: Reading database failure
    2: invalid digit found in string


Error pretty:
 Error {
    context: "Service could not do the important work",
    source: Error {
        context: "Important work failed while reading database",
        source: Error {
            context: "Reading database failure",
            source: ParseIntError {
                kind: InvalidDigit,
            },
        },
    },
}

Consequences

  • We have defined the following aliases that should be used by default:
    • StdResult: the default result that should be returned by a function (unless a more specific type is required).
    • StdError: the default error that should be used (unless a more specific type is required).
/* Code extracted from mithril-common::lib.rs */
/// Generic error type
pub type StdError = anyhow::Error;

/// Generic result type
pub type StdResult<T> = anyhow::Result<T, StdError>;
  • The function that returns an error from a sub-system should systematically add a context to the error with the with_context method, in order to provide clear stack traces and ease debugging.

  • When printing an StdError we should use the debug format without the pretty modifier, ie:

println!("Error debug:\n {error:?}\n\n");
  • When wrapping an error in a thiserror enum variant we should use the source attribute that will provide a clearer stack trace:
/// Correct usage with `source` attribute
#[derive(Error, Debug)]
pub enum DomainError {
    #[error("Anyhow error")]
    AnyhowWrapWithSource(#[source] StdError),
}
/// Incorrect usage without `source` attribute
#[derive(Error, Debug)]
pub enum DomainError {
    #[error("Anyhow error: {0}")]
    AnyhowWrapWithoutAnything(StdError),
}
  • Here are some tips on how to discriminate between creating a new error using thiserror or using an StdResult:
    • If you raise an anyhow error which only contains a string this means that you are creating a new error that doesn't come from a sub-system. In that case you should create a type using thiserror intead, ie:
// Avoid
return Err(anyhow!("my new error"));

// Prefer
#[derive(Debug,Error)]
pub enum MyError {
  MyNewError
}
return Err(MyError::MyNewError);
  • (Still undecided) You should avoid wrapping a StdError in a thiserror type. This breaks the stack trace and makes it really difficult to retrieve the innermost errors using downcast_ref. When the thiserror type is itself wrapped in a StdError afterward, you would have to downcast_ref twice: first to get the thiserror type and then to get the innermost error. This should be restricted to the topmost errors of our system (ie the state machine errors).