Error Handling
Much of this section applies to both async and non-async code. Async code has a few extra considerations: you are probably managing large amounts of IO, and really don't want to stop the world when an error occurs!
Rust Error Handling
In previous examples, we've used unwrap()
or expect("my message")
to get the value out of a Result
. If an error occurred, your program (or thread) crashes. That's not great for production code!
Aside: Sometimes, crashing is the right thing to do. If you can't recover from an error, crashing is preferable to trying to continue and potentially corrupting data.
So what is a Result?
A Result
is an enum
, just like we covered in week 1. It's a "sum type"---it can be one of two things---and never both. A Result
is either Ok(T)
or Err(E)
. It's deliberately hard to ignore errors!
This differs from other languages:
Language | Description | Error Types |
---|---|---|
C | Errors are returned as a number, or even NULL. It's up to you to decipher what the library author meant. Convention indicates that returning <0 is an error, and >=0 is success. | int |
C++ | Exceptions, which are thrown and "bubble up the stack" until they are caught in a catch block. If an exception is uncaught, the program crashes. Exceptions can have performance problems. Many older C++ programs use the C style of returning an error code. Some newer C++ programs use std::expected and std::unexpected to make it easier to handle errors without exceptions. | std::exception , expected , int , anything you like! |
Java | Checked exceptions---which are like exceptions, but handling them is mandatory. Every function must declare what exceptions it can throw, and every caller must handle them. This is a great way to make sure you don't ignore errors, but it's also a great way to make sure you have a lot of boilerplate code. This can get a little silly, so you find yourself re-throwing exceptions to turn them into types you can handle. Java is also adding the Optional type to make it easier to handle errors without exceptions. | Exception , Optional |
Go | Functions can return both an error type and a value. The compiler won't let you forget to check for errors, but it's up to you to handle them. In-memory, you are often returning both the value and an empty error structure. | error |
Rust | Functions return an enum that is either Ok(T) or Err(E) . The compiler won't let you forget to check for errors, and it's up to you to handle them. Result is not an exception type, so it doesn't incur the overhead of throwing. You're always returning a value or an error, never both. | Result<T, E> |
So there's a wide range of ways to handle errors across the language spectrum. Rust's goal is to make it easy to work with errors, and hard to ignore them - without incurring the overhead of exceptions. However (there's always a however!), default standard-library Rust makes it harder than it should be.
Strongly Typed Errors: A Blessing and a Curse!
The code for this is in the
03_async/rust_errors1
directory.
Rust's errors are very specific, and can leave you with a lot of things to match. Let's look at a simple example:
use std::path::Path; fn main() { let my_file = Path::new("mytile.txt"); // This yields a Result type of String or an error let contents = std::fs::read_to_string(my_file); // Let's just handle the error by printing it out match contents { Ok(contents) => println!("File contents: {contents}"), Err(e) => println!("ERROR: {e:#?}"), } }
This prints out the details of the error:
ERROR: Os {
code: 2,
kind: NotFound,
message: "The system cannot find the file specified.",
}
That's great, but what if we want to do something different for different errors? We can match on the error type:
#![allow(unused)] fn main() { match contents { Ok(contents) => println!("File contents: {contents}"), Err(e) => match e.kind() { std::io::ErrorKind::NotFound => println!("File not found"), std::io::ErrorKind::PermissionDenied => println!("Permission denied"), _ => println!("ERROR: {e:#?}"), }, } }
The _
is there because otherwise you end up with a remarkably exhaustive list:
#![allow(unused)] fn main() { match contents { Ok(contents) => println!("File contents: {contents}"), Err(e) => match e.kind() { std::io::ErrorKind::NotFound => println!("File not found"), std::io::ErrorKind::PermissionDenied => println!("Permission denied"), std::io::ErrorKind::ConnectionRefused => todo!(), std::io::ErrorKind::ConnectionReset => todo!(), std::io::ErrorKind::ConnectionAborted => todo!(), std::io::ErrorKind::NotConnected => todo!(), std::io::ErrorKind::AddrInUse => todo!(), std::io::ErrorKind::AddrNotAvailable => todo!(), std::io::ErrorKind::BrokenPipe => todo!(), std::io::ErrorKind::AlreadyExists => todo!(), std::io::ErrorKind::WouldBlock => todo!(), std::io::ErrorKind::InvalidInput => todo!(), std::io::ErrorKind::InvalidData => todo!(), std::io::ErrorKind::TimedOut => todo!(), std::io::ErrorKind::WriteZero => todo!(), std::io::ErrorKind::Interrupted => todo!(), std::io::ErrorKind::Unsupported => todo!(), std::io::ErrorKind::UnexpectedEof => todo!(), std::io::ErrorKind::OutOfMemory => todo!(), std::io::ErrorKind::Other => todo!(), _ => todo!(), }, } }
Many of those errors aren't even relevant to opening a file! Worse, as the Rust standard library grows, more errors can appear---meaning a rustup update
run could break your program. That's not great! So when you are handling individual errors, you should always use the _
to catch any new errors that might be added in the future.
Pass-Through Errors
The code for this is in the
03_async/rust_errors2
directory.
If you are just wrapping some very simple functionality, you can make your function signature match the function you are wrapping:
use std::path::Path; fn maybe_read_a_file() -> Result<String, std::io::Error> { let my_file = Path::new("mytile.txt"); std::fs::read_to_string(my_file) } fn main() { match maybe_read_a_file() { Ok(text) => println!("File contents: {text}"), Err(e) => println!("An error occurred: {e:?}"), } }
No need to worry about re-throwing, you can just return the result of the function you are wrapping.
The ?
Operator
We mentioned earlier that Rust doesn't have exceptions. It does have the ability to pass errors up the call stack---but because they are handled explicitly in return
statements, they don't have the overhead of exceptions. This is done with the ?
operator.
Let's look at an example:
#![allow(unused)] fn main() { fn file_to_uppercase() -> Result<String, std::io::Error> { let contents = maybe_read_a_file()?; Ok(contents.to_uppercase()) } }
This calls our maybe_read_a_file
function and adds a ?
to the end. What does the ?
do?
- If the
Result
type isOk
, it extracts the wrapped value and returns it---in this case tocontents
. - If an error occurred, it returns the error to the caller.
This is great for function readability---you don't lose the "flow" of the function amidst a mass of error handling. It's also good for performance, and if you prefer the "top down" error handling approach it's nice and clean---the error gets passed up to the caller, and they can handle it.
What if I just want to ignore the error?
You must handle the error in some way. You can just call the function:
#![allow(unused)] fn main() { file_to_uppercase(); }
This will generate a compiler warning that there's a Result
type that must be used. You can silence the warning with an underscore:
#![allow(unused)] fn main() { let _ = file_to_uppercase(); }
_
is the placeholder symbol - you are telling Rust that you don't care. But you are explicitly not caring---you've told the compiler that ignoring the error is a conscious decision!
You can also use the if let
pattern and simply not add an error handler:
#![allow(unused)] fn main() { if let Ok(contents) = file_to_uppercase() { println!("File contents: {contents}"); } }
What About Different Errors?
The ?
operator is great, but it requires that the function support exactly the type of error that you are passing upwards. Otherwise, in a strong-typed language you won't be able to ensure that errors are being handled.
Let's take an example that draws a bit from our code on day 1.
The code for this is in the
03_async/rust_errors3
directory.
Let's add Serde and Serde_JSON to our project:
cargo add serde -F derive
cargo add serde_json
And we'll quickly define a deserializable struct:
#![allow(unused)] fn main() { use std::path::Path; use serde::Deserialize; #[derive(Deserialize)] struct User { name: String, password: String, } fn load_users() { let my_file = Path::new("users.json"); let raw_text = std::fs::read_to_string(my_file)?; let users: Vec<User> = serde_json::from_str(&raw_text)?; Ok(users) } }
This isn't going to compile yet, because we aren't returning a type from the function. So we add a Result
:
#![allow(unused)] fn main() { fn load_users() -> Result<Vec<User>, Error> { }
Oh no! What do we put for Error
? We have a problem! read_to_string
returns an std::io::Error
type, and serde_json::from_str
returns a serde_json::Error
type. We can't return both!
Boxing Errors
There's a lot of typing for a generic error type, but it works:
#![allow(unused)] fn main() { type GenericResult<T> = std::result::Result<T, Box<dyn std::error::Error>>; fn load_users() -> GenericResult<Vec<User>> { let my_file = Path::new("users.json"); let raw_text = std::fs::read_to_string(my_file)?; let users: Vec<User> = serde_json::from_str(&raw_text)?; Ok(users) } }
This works with every possible type of error. Let's add a main
function and see what happens:
fn main() { let users = load_users(); match users { Ok(users) => { for user in users { println!("User: {}, {}", user.name, user.password); } }, Err(err) => { println!("Error: {err}"); } } }
The result prints:
Error: The system cannot find the file specified. (os error 2)
You have the exact error message, but you really don't have any way to tell what went wrong programmatically. That may be ok for a simple program.
Easy Boxing with Anyhow
There's a crate named anyhow
that makes it easy to box errors. Let's add it to our project:
cargo add anyhow
Then you can replace the Box
definition with anyhow::Error
:
#![allow(unused)] fn main() { fn anyhow_load_users() -> anyhow::Result<Vec<User>> { let my_file = Path::new("users.json"); let raw_text = std::fs::read_to_string(my_file)?; let users: Vec<User> = serde_json::from_str(&raw_text)?; Ok(users) } }
It still functions the same way:
Error: The system cannot find the file specified. (os error 2)
In fact, anyhow
is mostly just a convenience wrapper around Box
and dyn
. But it's a very convenient wrapper!
Anyhow does make it a little easier to return your own error:
#![allow(unused)] fn main() { #[allow(dead_code)] fn anyhow_load_users2() -> anyhow::Result<Vec<User>> { let my_file = Path::new("users.json"); let raw_text = std::fs::read_to_string(my_file)?; let users: Vec<User> = serde_json::from_str(&raw_text)?; if users.is_empty() { anyhow::bail!("No users found"); } if users.len() > 10 { return Err(anyhow::Error::msg("Too many users")); } Ok(users) } }
I've included the short-way and the long-way - they do the same thing. bail!
is a handy macro for "error out with this message". If you miss Go-like "send any error you like", anyhow
has your back!
As a rule of thumb:
anyhow
is great in client code, or code where you don't really care what went wrong---you care that an error occurred and should be reported.