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_BACKTRACE
environment variable is set to1
orfull
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
andanyhow
crates 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_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 thesource
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 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
thiserror
intead, 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
StdError
in athiserror
type. This breaks the stack trace and makes it really difficult to retrieve the innermost errors usingdowncast_ref
. When thethiserror
type is itself wrapped in aStdError
afterward, you would have todowncast_ref
twice: first to get thethiserror
type and then to get the innermost error. This should be restricted to the topmost errors of our system (ie the state machine errors).