|
| 1 | +--- |
| 2 | +slug: 6 |
| 3 | +title: | |
| 4 | + 6. Errors implementation Standard |
| 5 | +authors: |
| 6 | +- name: Mithril Team |
| 7 | +tags: [Draft] |
| 8 | +date: 2023-09-27 |
| 9 | +--- |
| 10 | + |
| 11 | +## Status |
| 12 | + |
| 13 | +Draft |
| 14 | + |
| 15 | +## Context |
| 16 | + |
| 17 | +Error handling is difficult with Rust: |
| 18 | +- Many ways of implementing them with different crates ([`thiserror`](https://crates.io/crates/thiserror), [`anyhow`](https://crates.io/crates/anyhow), ...) |
| 19 | +- No exception like handling of errors |
| 20 | +- No stack trace or context available by default |
| 21 | +- Backtrace uniquely when a panic occurs and if `RUST_BACKTRACE` environment variable is set to `1` or `full` |
| 22 | + |
| 23 | +We think the errors handling should be done in a consistent way in the project. |
| 24 | +Thus we have worked on a standardization of their implementation and tried to apply it to the whole repository. |
| 25 | +This has enabled us to have a clear vision of the do and don't that we intend to summarize in this ADR. |
| 26 | + |
| 27 | +## Decision |
| 28 | + |
| 29 | +_Therefore_ |
| 30 | + |
| 31 | +* We have decided to use `thiserror` and `anyhow` crates to implement the errors: |
| 32 | + * [`thiserror`](https://crates.io/crates/thiserror) is used to create module or domain errors that come from our developments and can be easily identified (as they are strongly typed). |
| 33 | + * [`anyhow`](https://crates.io/crates/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. |
| 34 | + |
| 35 | +Here is a [Rust playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=bf667c443696beb90106f6ae627a57b9) that summarizes the usage of `thiserror`: |
| 36 | + |
| 37 | +```rust |
| 38 | +#[allow(unused_imports)] |
| 39 | +use anyhow::{anyhow, Context, Result}; // 1.0.71 |
| 40 | +use thiserror::Error; // 1.0.43 |
| 41 | + |
| 42 | +#[derive(Error, Debug)] |
| 43 | +#[error("Codec error: {msg}")] |
| 44 | +pub struct CodecError { |
| 45 | + msg: String, |
| 46 | + #[source] // optional if field name is `source` |
| 47 | + source: anyhow::Error, |
| 48 | +} |
| 49 | + |
| 50 | +#[derive(Error, Debug)] |
| 51 | +pub enum DomainError { |
| 52 | + #[error("Error with codec: {0:?}")] |
| 53 | + CodecWithOnlyDebug(CodecError), |
| 54 | + |
| 55 | + #[error("Error with codec")] |
| 56 | + CodecWithSource(#[source] CodecError), |
| 57 | + |
| 58 | + #[error("Error with codec: {0}")] |
| 59 | + CodecWithoutAnything(CodecError), |
| 60 | + |
| 61 | + #[error("Anyhow error: {0:?}")] |
| 62 | + AnyhowWrapWithOnlyDebug(anyhow::Error), |
| 63 | + |
| 64 | + #[error("Anyhow error")] |
| 65 | + AnyhowWrapWithSource(#[source] anyhow::Error), |
| 66 | + |
| 67 | + #[error("Anyhow error: {0}")] |
| 68 | + AnyhowWrapWithoutAnything(anyhow::Error), |
| 69 | +} |
| 70 | + |
| 71 | +fn anyhow_result() -> Result<()> { |
| 72 | + "invalid_number" |
| 73 | + .parse::<u64>() |
| 74 | + .map(|_| ()) |
| 75 | + .with_context(|| "Reading database failure") |
| 76 | +} |
| 77 | + |
| 78 | +fn thiserror_struct() -> Result<(), CodecError> { |
| 79 | + Err(CodecError { |
| 80 | + msg: "My message".to_string(), |
| 81 | + source: anyhow!("Could not decode config"), |
| 82 | + })?; |
| 83 | + Ok(()) |
| 84 | +} |
| 85 | + |
| 86 | +fn print_error(title: &str, error: anyhow::Error) { |
| 87 | + println!("{title:-^80}"); |
| 88 | + println!("{error:?}\n",); |
| 89 | +} |
| 90 | + |
| 91 | +fn main() { |
| 92 | + println!("1 - Printing errors from enum variant that contains a error struct\n"); |
| 93 | + // Debug the inner error struct: "normal" debug without the anyhow touch |
| 94 | + print_error( |
| 95 | + "DomainError::CodecWithOnlyDebug", |
| 96 | + anyhow!(DomainError::CodecWithOnlyDebug( |
| 97 | + thiserror_struct().unwrap_err() |
| 98 | + )), |
| 99 | + ); |
| 100 | + // marking the inner error struct as source: anyhow will be able to make a |
| 101 | + // stacktrace out of this error. Nice ! |
| 102 | + print_error( |
| 103 | + "DomainError::CodecWithSource", |
| 104 | + anyhow!(DomainError::CodecWithSource( |
| 105 | + thiserror_struct().unwrap_err() |
| 106 | + )), |
| 107 | + ); |
| 108 | + // without debugging the inner error: only show the error text |
| 109 | + print_error( |
| 110 | + "DomainError::CodecWithoutAnything", |
| 111 | + anyhow!(DomainError::CodecWithoutAnything( |
| 112 | + thiserror_struct().unwrap_err() |
| 113 | + )), |
| 114 | + ); |
| 115 | + |
| 116 | + println!("\n2 - Printing errors from enum variant that contains a anyhow error\n"); |
| 117 | + // using only debug: the first two errors of the stack will be merged |
| 118 | + print_error( |
| 119 | + "DomainError::AnyhowWrapWithOnlyDebug", |
| 120 | + anyhow!(DomainError::AnyhowWrapWithOnlyDebug( |
| 121 | + anyhow_result().with_context(|| "context").unwrap_err() |
| 122 | + )), |
| 123 | + ); |
| 124 | + // using #[source] attribute: each error of the stack will have a line |
| 125 | + print_error( |
| 126 | + "DomainError::AnyhowWrapWithSource", |
| 127 | + anyhow!(DomainError::AnyhowWrapWithSource( |
| 128 | + anyhow_result().with_context(|| "context").unwrap_err() |
| 129 | + )), |
| 130 | + ); |
| 131 | + // without debug nor source: only the uppermost error is print |
| 132 | + print_error( |
| 133 | + "DomainError::AnyhowWrapWithoutAnything", |
| 134 | + anyhow!(DomainError::AnyhowWrapWithoutAnything( |
| 135 | + anyhow_result().with_context(|| "context").unwrap_err() |
| 136 | + )), |
| 137 | + ); |
| 138 | +} |
| 139 | + |
| 140 | +``` |
| 141 | + |
| 142 | +Which will output errors this way: |
| 143 | + |
| 144 | +``` |
| 145 | +1 - Printing errors from enum variant that contains a error struct |
| 146 | +
|
| 147 | +------------------------DomainError::CodecWithOnlyDebug------------------------- |
| 148 | +Error with codec: CodecError { msg: "My message", source: Could not decode config } |
| 149 | +
|
| 150 | +--------------------------DomainError::CodecWithSource-------------------------- |
| 151 | +Error with codec |
| 152 | +
|
| 153 | +Caused by: |
| 154 | + 0: Codec error: My message |
| 155 | + 1: Could not decode config |
| 156 | +
|
| 157 | +-----------------------DomainError::CodecWithoutAnything------------------------ |
| 158 | +Error with codec: Codec error: My message |
| 159 | +
|
| 160 | +
|
| 161 | +2 - Printing errors from enum variant that contains a anyhow error |
| 162 | +
|
| 163 | +----------------------DomainError::AnyhowWrapWithOnlyDebug---------------------- |
| 164 | +Anyhow error: context |
| 165 | +
|
| 166 | +Caused by: |
| 167 | + 0: Reading database failure |
| 168 | + 1: invalid digit found in string |
| 169 | +
|
| 170 | +-----------------------DomainError::AnyhowWrapWithSource------------------------ |
| 171 | +Anyhow error |
| 172 | +
|
| 173 | +Caused by: |
| 174 | + 0: context |
| 175 | + 1: Reading database failure |
| 176 | + 2: invalid digit found in string |
| 177 | +
|
| 178 | +---------------------DomainError::AnyhowWrapWithoutAnything--------------------- |
| 179 | +Anyhow error: context |
| 180 | +``` |
| 181 | + |
| 182 | +Here is a [Rust playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=90f962ab001d2ea0321fc5da0d4ec0f1) that summarizes the usage of the `context` feature form `anyhow`: |
| 183 | + |
| 184 | +```rust |
| 185 | +#[allow(unused_imports)] |
| 186 | +use anyhow::{anyhow, Context, Result}; // 1.0.71 |
| 187 | + |
| 188 | +fn read_db() -> Result<()> { |
| 189 | + "invalid_number" |
| 190 | + .parse::<u64>() |
| 191 | + .map(|_| ()) |
| 192 | + .with_context(|| "Reading database failure") |
| 193 | +} |
| 194 | + |
| 195 | +fn do_work() -> Result<()> { |
| 196 | + read_db().with_context(|| "Important work failed while reading database") |
| 197 | +} |
| 198 | + |
| 199 | +fn do_service_work() -> Result<()> { |
| 200 | + do_work().with_context(|| "Service could not do the important work") |
| 201 | +} |
| 202 | + |
| 203 | +fn main() { |
| 204 | + let error = do_service_work().unwrap_err(); |
| 205 | + |
| 206 | + println!("Error string:\n {error}\n\n"); |
| 207 | + println!("Error debug:\n {error:?}\n\n"); |
| 208 | + println!("Error pretty:\n {error:#?}\n\n"); |
| 209 | +} |
| 210 | + |
| 211 | +``` |
| 212 | + |
| 213 | +Which will output errors this way: |
| 214 | +``` |
| 215 | +Error string: |
| 216 | + Service could not do the important work |
| 217 | +
|
| 218 | +
|
| 219 | +Error debug: |
| 220 | + Service could not do the important work |
| 221 | +
|
| 222 | +Caused by: |
| 223 | + 0: Important work failed while reading database |
| 224 | + 1: Reading database failure |
| 225 | + 2: invalid digit found in string |
| 226 | +
|
| 227 | +
|
| 228 | +Error pretty: |
| 229 | + Error { |
| 230 | + context: "Service could not do the important work", |
| 231 | + source: Error { |
| 232 | + context: "Important work failed while reading database", |
| 233 | + source: Error { |
| 234 | + context: "Reading database failure", |
| 235 | + source: ParseIntError { |
| 236 | + kind: InvalidDigit, |
| 237 | + }, |
| 238 | + }, |
| 239 | + }, |
| 240 | +} |
| 241 | +``` |
| 242 | + |
| 243 | +## Consequences |
| 244 | + |
| 245 | +* We have defined the following aliases that should be used by default: |
| 246 | + * `StdResult`: the default result that should be returned by a function (unless a more specific type is required). |
| 247 | + * `StdError`: the default error that should be used (unless a more specific type is required). |
| 248 | + |
| 249 | +```rust |
| 250 | +/* Code extracted from mithril-common::lib.rs */ |
| 251 | +/// Generic error type |
| 252 | +pub type StdError = anyhow::Error; |
| 253 | + |
| 254 | +/// Generic result type |
| 255 | +pub type StdResult<T> = anyhow::Result<T, StdError>; |
| 256 | +``` |
| 257 | + |
| 258 | +* 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. |
| 259 | + |
| 260 | +* When printing an `StdError` we should use the debug format without the pretty modifier, ie: |
| 261 | +```rust |
| 262 | +println!("Error debug:\n {error:?}\n\n"); |
| 263 | +``` |
| 264 | + |
| 265 | +* When wrapping an error in a `thiserror` enum variant we should use the `source` attribute that will provide a clearer stack trace: |
| 266 | +```rust |
| 267 | +/// Correct usage with `source` attribute |
| 268 | +#[derive(Error, Debug)] |
| 269 | +pub enum DomainError { |
| 270 | + #[error("Anyhow error")] |
| 271 | + AnyhowWrapWithSource(#[source] StdError), |
| 272 | +} |
| 273 | +``` |
| 274 | + |
| 275 | +```rust |
| 276 | +/// Incorrect usage without `source` attribute |
| 277 | +#[derive(Error, Debug)] |
| 278 | +pub enum DomainError { |
| 279 | + #[error("Anyhow error: {0}")] |
| 280 | + AnyhowWrapWithoutAnything(StdError), |
| 281 | +} |
| 282 | +``` |
| 283 | + |
| 284 | +* Here are some tips on how to discriminate between creating a new error using `thiserror` or using an `StdResult`: |
| 285 | + * 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: |
| 286 | +```rust |
| 287 | +// Avoid |
| 288 | +return Err(anyhow!("my new error")); |
| 289 | + |
| 290 | +// Prefer |
| 291 | +#[derive(Debug,Error)] |
| 292 | +pub enum MyError { |
| 293 | + MyNewError |
| 294 | +} |
| 295 | +return Err(MyError::MyNewError); |
| 296 | +``` |
| 297 | + |
| 298 | + * (*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. |
| 299 | + This should be restricted to the topmost errors of our system (ie the state machine errors). |
0 commit comments