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:

LanguageDescriptionError Types
CErrors 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!
JavaChecked 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
GoFunctions 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
RustFunctions 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 is Ok, it extracts the wrapped value and returns it---in this case to contents.
  • 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.