When Ferrous Metals Corrode, pt. VI

Intro

This post corresponds to "Chapter 7. Error Handling" in the book. It's a short chapter on Rusts panic and Result types.

Unlike Python, and similar to Go, Rust doesn't use Exceptions to handle errors. Instead, "expected" errors (things like EPERM or ENOENT, i.e. stuff that just can happen when running a program) are handled by returning an Result with some Err value.

"Unexpected" errors, on the other hand, are handled via panicking

Panic

Panics are a bit like RuntimeExceptions in Java; generally a panic is a bug in the program.

Panics are created by the Rust runtime or by the programmer with the panic!() macro.

Conditions that will cause panics:

  • .expect() on an Err result

  • Assertion failures

  • Out-of-bounds array access

  • Zero division

Panics are created per thread. Rust can either unwind the stack, or optionally just abort. By default no stack trace is printed unless the env has RUST_BACKTRACE=1

Unwinding the stack will drop any vars that were in use, open files are closed. User-defined drop methods are called as well.

It's possible to recover from a panic via the standard library function std::panic::catch_unwind(). This is useful e.g. for test harnesses, or interfacing to ext. programs.

If during a .drop() method a second panic happens, Rust will stop unwinding and abort the whole process. Aborting can also be made the default when compiling with -C panic=abort – this can reduce the size of the binary.

Result

Functions that can fail should return a Result; this can then be a success result Ok(v) or an error Err(e)

The equivalent of try/except for handling errors would be something like this match:

match get_weather(hometown) {
  Ok(report) => {
      display_weather(hometown, &report);
  }
  Err(err) => {
      println!("error querying the weather: {}", err);
      schedule_weather_retry();
  }
}  

Methods for common cases:

  • result.is_ok(), result.is_err() returns a bool

  • result.ok() returns an Option<T> either some value or None

  • result.unwrap_or(fallback) returns a success or a default fallback

  • result.unwrap_or_else(fallback_fn) similar to above, but takes a fun

  • result.unwrap(), result.expect(message) will return a sucess value or panic; .expect() lets you specify a message

  • result.as_ref(), result.as_mut() will convert Result<T, E> to a Result<&T, &E> (resp. a mutable ref). Useful if you don't want to consume the result right away.

Some libraries use a type alias, so that a shorthand can be used for declaring errors. E.g. std::io has this

pub type Result<T> = result::Result<T, Error>;

...
fn remove_file(path: &Path) -> Result<()>    

This hardcodes std::io::Error as the error type, as this is being used throughout the lib

The various Error types implement a common interface which supports methods for printing and other inspection.

Printing with either the {} or {:?} format specifiers:

// result of `println!("error: {}", err);`
error: failed to look up address information: No address associated with
hostname

// result of `println!("error: {:?}", err);` -- {:?} format prints extra debug info
error: Error { repr: Custom(Custom { kind: Other, error: StringError(
"failed to look up address information: No address associated with
hostname") }) }

To get the error message as a String use .to_string()

Those formats don't produce a stack trace, neither does err.to_string(). There is a method to get at underlying errors, if any: err.source(). There appears to be ongoing work on improving the stack trace situation in RFC 2504.

The anyhow crate has some helpers for error handling, and the thiserror crate has macros for defining custom errors.

Typically we let error results bubble up the call stack. We've already seen the ? operator that conventiently unwraps success, and returns with an Err value:

let data = read_data_file(data_path)?;


// equiv. match expr.:
let data = match read_data_file(data_path) {
    Ok(success_value) => success_value,
    Err(err) => return Err(err)
};

Older Rust versions used the try!() macro, as the ? operator was only added in Rust 1.13. The ? operator also works with Option, where it'll early-return a None value.

The ? of course implies that a function needs a Result return type. Often we will want to bubble up several types of errors, but how can we define several error types in a Result?

The first method is converting those errors to a custom error, and propagate that (see the thiserror crate for macros to support that). The second option is a conversion to Box and define a generic error and result type like this:

type GenericError = Box<dyn std::error::Error + Send + Sync + 'static>;
type GenericResult<T> = Result<T, GenericError>;

// example usage ... 
fn my_io_fn(file: &mut dyn BufRead) -> GenericResult<Vec<i64>> { ... }

There is no try / except X / except Y in Rust. To handle different errors specifically, the err.downcast_ref() method lets you fish out a ref to the actual error value:

match read_data() {
    Ok(data) => data,
    Err(err) => {
        if let Some(eperm) = err.downcast_ref::<MissingSemicolonError>() {
            insert_semicolon_in_source_code(mse.file(), mse.line())?;
            continue;  // try again!
        }
        return Err(err);
    }
}

For defining custom errors the thiserror crate – already mentioned above – has helpers. For example:

use thiserror::Error;
#[derive(Error, Debug)]
#[error("{message:} ({line:}, {column})")]
pub struct JsonError {
    message: String,
    line: usize,
    column: usize,
}
// ...

// usage
return Err(JsonError {
    message: "foobared here".to_string(),
    line: current_line,
    column: current_column
});

Coda

I've come to dislike Pythons way of error handling via exceptions because it creates an extra control path, and often for things that are not very exceptional at all to boot (looking at you, StopIteration and also many errors from the os lib, file perm. errors and such). Rusts error handling via Results for "expected" errors and the ? operator – and panics for more serious things – feels much easier to reason about. It's a bummer we can't have stack traces in stable though, those things are useful. Second gripe, the procedure to create custom errors feels more complicated than it should be, even with the help of thiserror.

The book makes a good point about partial results. Often when doing a bulk operation you're going to want to process the entire dataset, regardless if individual records fail to process or not, and only later deal with faults. By using Results, you can just naturally store those in a vector or some other collection, and do the error processing once all the data has been churned through.