6. Errors implementation Standard
Status
Accepted
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_BACKTRACEenvironment variable is set to1orfull
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 thiserrorandanyhowcrates to implement the errors:
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_contextmethod, in order to provide clear stack traces and ease debugging.
- 
When printing an StdErrorwe should use the debug format without the pretty modifier, ie:
println!("Error debug:\n {error:?}\n\n");
- When wrapping an error in a thiserrorenum variant we should use thesourceattribute 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 thiserroror using anStdResult:- 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 thiserrorintead, ie:
 
- 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 
// 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 StdErrorin athiserrortype. This breaks the stack trace and makes it really difficult to retrieve the innermost errors usingdowncast_ref. When thethiserrortype is itself wrapped in aStdErrorafterward, you would have todowncast_reftwice: first to get thethiserrortype and then to get the innermost error. This should be restricted to the topmost errors of our system (ie the state machine errors).