Error handling's bizarre adventure: Battle Tendency
The fact that some operations fail, errors occur and need to be processed is axiomatic
in programming. Over the history of computing, we have had several different models of
error handling. With early languages, there was no set standard, and you had to work with
what you got from a particular library, in C, there is a special thread-local global variable
mechanism via errno that you have to check manually, some languages have the concept of an Error
built into them, other follow the exception model.
If you are PHP, you have all of the aforementioned, everything burns, this is hell, God has abandoned us.
Rust sought to eliminate the mess that exceptions and other forms of error-handling can be. This is done by having none of these models, instead leveraging the type system Rust offers.
This eliminates perhaps one of the more important issues of some error-handling models: that you have no way of knowing if an operation is fallible without looking into the source code itself in many cases. That is, the fallibility of an operation (or nullability) is not included in the type information of a function.
To speak less in theoretical, let's address the two main facilities of failure Rust offers.
Panics
You have likely already encountered a panic. C programmers I have met are sometimes tempted to equate a panic with a segmentation fault, but that is not actually true. Segmentation fault necessarily indicates a memory error, attempt to write where you shouldn't or attempt to read where you cannot read from. Some segmentation faults may occur seemingly randomly because the illegally accessed memory may or may not be available depending on a number of factors. Segfaults are difficult to debug and do not by default come with a stacktrace - you have to use a debugger.
A panic is a controlled termination of a program (or a particular thread) on the basis of a failure deemed irrecoverable.
By default, execution will not just cease, but instead, the stack is unrolled and all values are dropped properly, ensuring that proper cleanup has been done and all resources have been returned to the operating system.
Only then will execution cease with a specialized error message and stacktrace, the display
of which can be controlled with the RUST_BACKTRACE environment variable.
env RUST_BACKTRACE=full cargo run
This will show the full backtrace if the program panics
What is considered an irrecoverable failure depends on you. In general, Rust considers things
that would create illegal memory access, such as indexing indexable structures out of bounds
with the [] operator (use .get() instead if you do not know the length ahead and cannot ensure
you are indexing with a legitimate index), or electing to not handle the possibility of
a None in Option or Err in Result.
In general, you can cause panics easily with the following methods and macros:
Option::unwrap(),Result::unwrap()Option::expect(msg),Result::expect(msg)- lets you add a custom messagepanic!(optional_msg)- formatted likeprintln!()etc.unimplemented!()- same as abovetodo!()- same as above, is an alias forunimplemented!()unreachable!()- same as above
There are other ways of causing panics, but none are as common as the above.
TIP: Keep in mind that overflowing math panics in debug builds due to debug asserts, but silently overflows in release builds. It is always a good idea to handle the possibility of an overflow with the appropriate safe mathematical method such as
.overflowing_add()which let you control the behavior in case an overflow might occur.
However, panics do not necessarily look good, so it is a good idea to handle them if possible.
Result and Error
When something is fallible in a way that can be handled or considered recoverable, then by
convention, the Result type is used.
You should be familiar with this type from the chapter on Results and Options. If not, check it out.
Errors are any type that implements the std::error::Error trait. The error trait is a supertrait of both Debug
and Display, so your type has to implement these also.
Errors must describe themselves through the Display and Debug traits. Error messages are typically concise lowercase sentences without trailing punctuation:
#![allow(unused)] fn main() { let err = "NaN".parse::<u32>().unwrap_err(); assert_eq!(err.to_string(), "invalid digit found in string"); }
Errors may provide cause chain information. Error::source() is generally used when errors cross “abstraction boundaries”.
If one module must report an error that is caused by an error from a lower-level module,
it can allow accessing that error via Error::source(). This makes it possible for the high-level module to
provide its own errors while also revealing some of the implementation for debugging via source chains.
In the most minimal case, if your Error does not require a special description beyond its Display implementation,
it is enough to provide an empty impl block to turn your type into an Error:
#![allow(unused)] fn main() { use std::fmt; // most laconic error #[derive(Debug)] struct MyError; impl fmt::Display for MyError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // this is all the error text you get in ***ED, THE STANDARD EDITOR*** write!(f, "?") } } impl std::error::Error for MyError {} }
Error types
Many libraries provide their own error types, and the standard library is no different. For example, this is the IO Error type that's returned by much of the IO, networking and Filesystem functionality:
https://doc.rust-lang.org/std/io/struct.Error.html
This one does not fit the mold, by Errors are typically enums which group all the possible scenarios that might go wrong.
It stands to reason that implementing custom errors all the time and all the required handling (because it is an enum) could get slightly tedious, so a number of error-handling frameworks have been popping up in Rust over the time.
Before we jump into them let's first consider error conversions, as it is an important topic.
Error conversions
In the chapter on Options and Results we mentioned the try ? operator. This allows a function to cease its
execution early returning an error that was encountered, otherwise continuing execution with the operations'
value unwrapped from the Option or Result.
An Option is not the same type as Result, and a Result with one combination of Ok and Error types is different type from a Result with a different combination.
The operator would be quite unwieldy if we first had to make cumbersome conversions to either Option or
a one specific combination of Result<T, E>.
Therefore, the operator can facilitate a number of conversions, depending on the target type. All the conversions implemented by the target type (in the case of an Result, it is the Error type that's important) are available.
If you want to be able to convert any error to a result, use Result<YourOutputType, Box<dyn Error>.
It might be interesting to know that even main() can return this Result to make things more ergonomic:
use std::fs::File; fn main() -> Result<(), Box<dyn Error>> { let f = File::open("bar.txt")?; Ok(()) }
It is possible to convert every Error type into a trait object, which is why you can use this.
Anyhow
The first of the frameworks important to us with regards to error handling is anyhow:
https://crates.io/crates/anyhow
It provides a flexible concrete Error type built on std::error::Error. It allows for rather easy idiomatic
error handling in Rust.
Often it is enough to just use anyhow's provided result type:
#![allow(unused)] fn main() { use anyhow::Result; fn get_cluster_info() -> Result<ClusterMap> { let config = std::fs::read_to_string("cluster.json")?; let map: ClusterMap = serde_json::from_str(&config)?; Ok(map) } }
Any Result will be converted to it.
However, you can also attach a context to help the person troubleshooting the error understand where things went wrong. A low-level error like "No such file or directory" can be annoying to debug without more context about what higher level step the application was in the middle of.
use anyhow::{Context, Result}; fn main() -> Result<()> { ... it.detach().context("Failed to detach the important thing")?; let content = std::fs::read(path) .with_context(|| format!("Failed to read instrs from {}", path))?; ... }
Keep in mind that using context is best for user-facing errors, since it is hard to process an opaque type like this.
But, if you really needed to, you can try downcasting to a particular error type anyway:
#![allow(unused)] fn main() { match root_cause.downcast_ref::<DataStoreError>() { Some(DataStoreError::Censored(_)) => Ok(Poll::Ready(REDACTED_CONTENT)), None => Err(error), } }
Downcasting follows the same convention as the std::any::Any trait.
Anyhow works with any type implementing std::error::Error trait. This may not be the case anymore
at the time of this reading, but Options had to be first converted into a Result, for example with
a context.
There are also two very handy macros in anyhow:
- One-off error messages can be constructed using the
anyhow!macro, which supports string interpolation and produces ananyhow::Error.
#![allow(unused)] fn main() { return Err(anyhow!("Missing attribute: {}", missing)); }
- A
bail!macro is provided as a shorthand for the same early return.
#![allow(unused)] fn main() { bail!("Missing attribute: {}", missing); }
In practice, there is very little reason to use anyhow! over bail!.
An easy way to make a concrete Error type for yourself is with thiserror.
Thiserror
This error provides a handy derive you can use on enum of your choosing.
#![allow(unused)] fn main() { use thiserror::Error; #[derive(Error, Debug)] pub enum DataStoreError { #[error("data store disconnected")] Disconnect(#[from] io::Error), #[error("the data for key `{0}` is not available")] Redaction(String), #[error("invalid header (expected {expected:?}, found {found:?})")] InvalidHeader { expected: String, found: String, }, #[error("unknown data store error")] Unknown, } }
Thiserror deliberately does not appear in your public API. You get the same thing as if you had written
an implementation of std::error::Error by hand, and switching from handwritten impls
to thiserror or vice versa is not a breaking change.
Errors may be enums, structs with named fields, tuple structs, or unit structs.
A Display impl is generated for your error if you provide #[error("...")] messages
on the struct or each variant of your enum, as shown above in the example.
The messages support a shorthand for interpolating fields from the error.
#[error("{var}")] ⟶ write!("{}", self.var)#[error("{0}")] ⟶ write!("{}", self.0)#[error("{var:?}")] ⟶ write!("{:?}", self.var)#[error("{0:?}")] ⟶ write!("{:?}", self.0)
These shorthands can be used together with any additional format args, which may be arbitrary expressions. For example:
#![allow(unused)] fn main() { #[derive(Error, Debug)] pub enum Error { #[error("invalid rdo_lookahead_frames {0} (expected < {})", i32::MAX)] InvalidLookahead(u32), } }
A From impl is generated for each variant containing a #[from] attribute.
Note that the variant must not contain any other fields beyond the source error and possibly a backtrace. A backtrace is captured from within the From impl if there is a field for it.i
#![allow(unused)] fn main() { #[derive(Error, Debug)] pub enum MyError { Io { #[from] source: io::Error, backtrace: Backtrace, }, } }
This allows you to very effectively convert from one error to another
The task: Simple grep
If you have been using Linux for any measure of time, you should be familiar with the grep program.
It's most basic functionality is searching for lines that match a (regex) pattern.
For this task, let's write a simple grep clone. This clone will only operate on one file, and it will only print lines matching a regular expression supplied as the first parameter on the command line.
Writing this program is quite easy, the main task here is to properly handle all possible errors. That is to say:
- no
unwrap()s and such
Use anyhow and thiserror at your discretion to handle all errors.
Be mindful of everything that can fail:
- missing cli arguments (consider this an error and quit with the correct usage message)
- missing file
- file cannot be read
- invalid regex
- cannot write into stdout (use
std::io::stdoutto write out the results) - anything else you might think of
Keep in mind that when importing errors, you can always alias them so as to not pollute your namespace with
use std::error::Error as IoError.
End product
In the end you should be left with a well prepared project, that has the following:
- documented code explaining your reasoning where it isn't self-evident
- optionally tests
- and an example or two where applicable
- clean git history that does not contain fix-ups, merge commits or malformed/misformatted commits
Your Rust code should be formatted by rustfmt / cargo fmt and should produce no
warnings when built. It should also work on stable Rust and follow the Braiins Standard