Error handling

Which errors in computer programs do you know? In small groups: Collect as many as you can, then try to group them into categories!

Working with errors in systems programming

  • How do I (as a programmer) know that:
    1. An error can happen
    2. An error has happened
  • How do I deal with an error?
    • Can I recover?
    • What kind of error is it? What was its cause?
  • These are language design questions

Aside: Terminology

  • The term error handling is often used to refer to the general approach to errors, not only the part that deals with an existing error
  • To disambiguate, we will use the term error model instead

What makes up an error model

Three different error models

Model Representation Signaling Propagation Handling Languages
Error Codes Primitive Types (e.g. int),
Composites, Interfaces
Return Value Return Value err == ERROR_NUMBER
err != nil
C
C++
Go
Software Exception Exception (Sub-)Class,
Primitives, Composites
Method Signature,
Documentation
Non-local jumps try/catch blocks C++
Java
Python
Result Types Variant / Tagged Union Return Value Return Value pattern matching Rust
Zig
Haskell

Error Codes (C)

int read_file(const char *filename) {
    FILE *fp = fopen(filename, "r");
    if (fp == NULL) { // handle other error
        fprintf(stderr, "Error opening file '%s': %s\n", 
            filename, strerror(errno));
        return -1; // propagate error
    }

    printf("File '%s' opened successfully.\n", filename);

    fclose(fp);
    return 0; // success
}

Error Codes (C) (cont.)

  • Representation: nullptr on a nullable type (e.g. FILE*) or simple integers
  • Signaling: The return type of a function
    • Problem: Is int a regular return value or an error?
    • Problem: What if I want to return an int and and error code?
  • Propagation: return ERROR_NUMBER;
    • Problem: fopen returns FILE*, read_file returns int, how to combine?
  • Handling: Check against sentinel value: fp == NULL, err != OK

Strengths and Weaknesses of Error Codes

  • Strengths:
    • Efficient because the error typically fits into a register
    • Work with all C code (because this is what C does)
    • Not limited to primitive types, we could return composite types with information
  • Weaknesses:
    • Signaling is subtle and easy to get wrong
    • Signaling mechanism blocks the return type
    • Propagation does not combine well (i.e. FILE* vs. int)
    • Handling is optional (what if I forget to check the return value?)

Software Exceptions (C++)

void read_file(const std::string& filename) {
    std::ifstream file(filename);
    if (!file.is_open()) { // handle other error(-ish)
        // propagate
        throw std::runtime_error("Error opening file: " + filename);
    }

    std::cout << "File '" << filename << "' opened successfully.\n";
}

int main(int argc, char** argv) {
    try {
        read_file(argv[1]);
    } catch(const std::runtime_error& e) { // handle exception
        std::cerr << "Runtime error: " << e.what() << '\n';
        return EXIT_FAILURE;
    }
    return EXIT_SUCCESS;
}

Software Exceptions (C++) (cont.)

  • Representation: Any value, typically a subclass of std::exception
  • Signaling: Nothing, go read the documentation
    • Problem: Easy to miss that something can throw
    • Though there is the inverse noexcept in C++
  • Propagation: Jump to the next matching catch block in the call stack
    • Problem: Where is the next matching catch block?
    • Problem: Requires stack unwinding (cleaning up local variables)
  • Handling: Execute code in a catch block

Strengths and Weaknesses of Software Exceptions

  • Strengths:
    • They don’t block the return type of a function
    • Fine-grained control over what to catch due to type matching
  • Weaknesses:
    • The signaling mechanism in C++ is bad (even with noexcept!)
      • Java at least has checked exceptions (throws IOException)
    • Stack unwinding has a performance overhead
  • Controversial:
    • Non-linear control flow

Exception Guarantees

  • What happens to the state of the program when an exception gets thrown in the middle of an operation (e.g. std::vector::push_back)?
  • The exception guarantee tells us:
    1. No guarantee: Anything is possible
    2. Basic exception guarantee: The program is left in a valid but unspecified state
    3. Strong exception guarantee: Program state is rolled back
    4. Nothrow guarantee: No exceptions will be thrown

Why Rust doesn’t need exception guarantees

  • Rust moves can’t fail!
    • They are bitwise copies
    • No arbitrary code gets executed
  • The only thing that could fail is the allocation, but we can react to that and simply drop the Vec
  • Also: Rust does not use exceptions!

The Rust error model

  • Why not simply use Option<T>?
  • Q: What is the difference between these two functions?
    • fn log(num: i32) -> Option<i32>
    • fn read_file(file: &str) -> Option<Vec<u8>>
  • What does the return type tell us? What does it not tell us?

Error representation using Option<T>

  • Option<T> does not contain information about the nature of the error
  • It is a very weak error representation (only error / no error)
  • What if a function has multiple potential error cases?

Result<T, E>

enum Result<T, E> {
    Ok(T),
    Err(E),
}
  • A similar type to Option<T>, but with an extra error type E
  • E can be anything: String, error code, complex error structure

Result<T, E> usage example

fn read_file(file_path: &Path) -> Result<Vec<u8>, String> {
    match std::fs::read(file_path) { // Error handling
        Ok(bytes) => Ok(bytes),
        Err(why) => // Propagation
            Err(format!("Could not read file ({})", why)), 
    }
}

// The same as:
std::fs::read(file_path)
    .map_err(|why| format!("Could not read file({})", why))

Error propagation

fn sum_numbers_in_file(file_path: &Path) -> Result<i64, String> {
    let lines = match std::fs::read_to_string(file_path) {
        Ok(s) => s.lines(),
        Err(why) => return Err(why.to_string()),
    };

    let mut sum = 0;
    for line in lines {
        match line.parse::<i64>() {
            Ok(num) => sum += num,
            Err(why) => return Err(why.to_string()),
        }
    }
    Ok(sum)
}

Error propagation

fn sum_numbers_in_file(file_path: &Path) -> Result<i64, String> {
    let lines = match std::fs::read_to_string(file_path) {
        Ok(s) => s.lines(),
        Err(why) => return Err(why.to_string()),
    };

    let mut sum = 0;
    for line in lines {
        match line.parse::<i64>() {
            Ok(num) => sum += num,
            Err(why) => return Err(why.to_string()),
        }
    }
    Ok(sum)
}
  • Common pattern: Give me value of fallible operation, or exit function with error if the operation failed

The ? operator

  • Use the ? operator to simplify this pattern:
fn sum_numbers_in_file(file_path: &Path) -> Result<i64, String> {
    let contents = std::fs::read_to_string(file_path)?;

    let mut sum = 0;
    for line in contents.lines() {
        sum += line.parse::<i64>()?;
    }
    Ok(sum)
}

// Or:
fn sum_numbers_in_file(file_path: &Path) -> Result<i64, String> {
    let contents = std::fs::read_to_string(file_path)?;

    contents
        .lines()
        .map(|l| l.parse::<i64>())
        .sum() // sum has special handling for Result<T, E>
}

The ? operator (cont.)

  • This almost works, but there is one problem:
    • read_to_string returns Result<String, std::io::Error>
    • parse returns Result<i64, ParseIntError>
  • The error types are incompatible!
  • Our options:
    • Manually convert each error with .map_error(|e| e.to_string())
    • Use a polymorphic error type (Box<dyn Error>)

Polymorphic error types

  • Rust has an Error trait:
pub trait Error: Debug + Display {
    fn source(&self) -> Option<&(dyn Error + 'static)> { ... }
    // ...and a bunch of other methods that are either deprecated 
    // or experimental
}
  • Display lets us convert an error to a human-readable message
  • source() lets us nest errors
    • e.g. sum-function error caused by file-system error
  • Build a universal result type using runtime polymorphism:
    • Result<T, Box<dyn Error>>
    • Use the anyhow crate for this exact type with better ergonomics

anyhow

use anyhow::Result;

fn sum_numbers_in_file(file_path: &Path) -> Result<i64> {
                                          //-------^^^- 
                                          //Only one type T, not <T, E>!
    let contents = std::fs::read_to_string(file_path)?;

    let mut sum = 0;
    for line in contents.lines() {
        sum += line.parse::<i64>()?;
    }
    Ok(sum)
}

anyhow + context

use anyhow::{Context, Result};

fn sum_numbers_in_file(file_path: &Path) -> Result<i64> {
    let contents = std::fs::read_to_string(file_path)
        .with_context(|| format!("failed to read file {}", file_path.display()))?;

    let mut sum = 0;
    for line in contents.lines() {
        sum += line
            .parse::<i64>()
            .with_context(|| format!("'{line}' is not a valid number"))?;
    }
    Ok(sum)
}
  • Use context and with_context to attach information to errors
    • with_context takes a fn and is executed lazily, good if creating the context is expensive (e.g. allocating strings through format!)
  • Downside: More code (error handling is verbose in Rust)

Alternative: thiserror

#[derive(Error, Debug)]
enum SumNumbersError {
    #[error("file read failed (cause: {0})")]
    FileReadFailed(#[from] std::io::Error),
    #[error("parsing failed (cause: {0})")]
    ParsingFailed(#[from] std::num::ParseIntError),
}

fn sum_numbers_in_file(file_path: &Path) -> Result<i64, SumNumbersError> {
    let contents = std::fs::read_to_string(file_path)?;

    let mut sum = 0;
    for line in contents.lines() {
        sum += line.parse::<i64>()?;
    }
    Ok(sum)
}
  • Define custom error type using #[derive(Error)] from the thiserror crate
    • Convert from other types by adding #[from]

anyhow vs. thiserror

  • When to use which? Are they equally good?
  • Your job: Evaluate both in the lab!

What if everything breaks?

  • Some errors can’t reasonably be handled
    • Logic errors (out-of-bounds memory access, trying to access Some variant of Option that is None)
    • Catastrophic situations (out-of-memory)
  • In this case Rust takes the stance that we should fail fast at a coarse grain
    • Coarse grain: Task boundary (i.e. terminating the current thread/task)
    • Fine grain: Function boundary (i.e. exiting the current function early)
  • For this we can use the panic! macro

panic!

fn main() {
    panic!("Panic from main");
}
thread 'main' panicked at 'Panic from main', /app/example.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
  • This is used internally in unwrap() and expect()
  • Use this sparingly!