RustBrock/Error Handling.md
2025-01-23 19:03:21 +00:00

20 KiB

Error Handing

This is a factor of life in software, Rust has a number of features for handling errors. One feature is that Rust requires you to acknowledge the possibility of an error and take some action beofre our code will compile.

This requirement ensures that errors are handled before the possiblity could arise

This can be split into two major categories

  • Recoverable - File a file not found, just need to report the problem to the user and retry the operation
  • Unrecoverable - A symptom of bugs, like trying to access a location beyond the end of an array. Need to immediately stop the program

Many languages dont distinguish between the two kinds of errors and handle them the same way using mechanisms such as exceptions

Rust does not have exceptions

Instead it has the type Result< T, E> for recoverable errors

It has the panc! macro to stop eecution when an unrecoverable error occurs

Unrecoverable Errors

Whne bad things happen in your code and nothing you can do nothing about it then Rust has the panc! macro

There are two ways to cause a panic:

  • by taking an action that causes the code to paic (like accessing an array past the end)
  • explicity calling panic! macro

By default these print a failure message, unwind, clean up the stack and then quit.

Using an environment variable you can also have Rust display the call stack when a panic occurs. This can make it easier to track down the source of the panic

When a call to panic! occurs the error message will be contained in the last two lines. The first line will contain our message and the second is when te source of this panic occured

example

fn main() {
    panic!("crash and burn");
}

This will output

thread 'main' panicked at src/main.rs:2:5:
crash and burn
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

This indicates that the panic occured in the file main.rs at the 2nd line on the 5th character

In this example it indicates that it is part of our source cdoe, looking there will show the panic! macro

In other cases the panic! call might be reported as someone else code where the panic! macro was called

You can also use the traceback functions of the panic call to figure ot the part of our code that caused the problem

To understand this an example will be used

fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

Here we are tryin to access the 100th element, this is out of range and therefore Rust will initiate a error

In C, attempting to read beyond hte end of a data structure is undefined behavior, and you might get whatever is at the memory location, this would be something "random"

This is considered a buffer overread and can lead to security vulnerabilities, this would allow an attacker to be able to manipulate the index in such a way that they shouldnt be allowed to sore in that data structure.

Rust protects yo from this kind of vulnerability by casuing a panic if you try to read something out of range.

The note: line tells us that we can set the RUST_BACKTRACE environment variable to get a backtrace to show exactly what happened to casue the error.

The key to reading a backtrace is to start at the top and read until you see the files you wrote, that is where the problem originates.

The lines above that spot are code that our code has called, and the lines below are the code that called your code. These before-and-after lines might include core Rust code, std lib code or crates that you are using

You can set the backtrace by setting the RUST_BACKTRACE environment variable to any value except 0

Example

RUST_BACKTRACE=1 cargo run
thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
stack backtrace:
   0: rust_begin_unwind
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/std/src/panicking.rs:645:5
   1: core::panicking::panic_fmt
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/panicking.rs:72:14
   2: core::panicking::panic_bounds_check
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/panicking.rs:208:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/slice/index.rs:255:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/slice/index.rs:18:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/alloc/src/vec/mod.rs:2770:9
   6: panic::main
             at ./src/main.rs:4:6
   7: core::ops::function::FnOnce::call_once
             at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

In order to a backtarace with this info, debug symbols must be enabled

Debug symbols are enabled by defualt when using cargo build or cargo run without the --release flag

In line 6 of the backtrace points to the line in our project that causes the problem, that would be line 4 of src/main.rs

If we dont want our program to panc thne we sould start our investigation at the inidcated line we wrote

Unwinding the Stack or Aborting in Response to a Panic

unwinding in rust means that it walks back up the stack and cleans up the data form each function it encounters.

However walking back and cleaning up is a lot of work

Rust also allows yo to choose the alternative of immediately aborting, which means ends the program without cleaning up

Memory that the program was using will thne be clean up by the OS

If yo need your project's resultant binary as small as possible you can switch from unwinfing to aborting upon a panic

This can be done by adding panic = 'abort' to the appropriate [profile] section in your Cargo.toml

example of this

[profile.release]
panic = 'abort'

Recoverable Errors

You can use the enum Result to handle most errors becasue they are not serious enough to warrant a panic

One example of this of a non serious error is opening a file and that operation fails becasue that file doesnt exist, you may want to create the file instead of terminating the process

the enum Result is defined as Which has two variants Ok and Err

enum Result<T, E> {
    Ok(T),
    Err(E),
}

T and E are generic type parameters

T reperesents the tpye of value that will be returned in a success case within th Ok variant E reperesents the tpye of the error that will be returned in a failure case within the Err variant

Because Result has these generic type parameters we can use the Result type and the functions defined on it in many different situations where the success value and error value we want to reutrn may differ

Lets use a function that retunrs a Result value because the function could fail

use std::fs::File;

fn main () {
    let greeting_file_result = File::open("hello.txt");
}

The return type of File::open is a Result<T, E> The generic parameter T has been filled in by the implementation of File::open with the type of the success value is a file handle (std::fs::File) The E parameter is used in the error value which is std::io::Error

This return type indicates that call may succeed and reutnr a file handle that we can read and write to or it may fail if it doesnt exist, or not having the correct permissions

File::open function needs a way to tell us whether it succeeded or failed hence the use of Result enum which conveys this message of failure or success

When File::open succeeds, the value in greeting_file_result will be an instance of Ok that contains a file handle When it fails the value in greeting_file_result will be an intance of Er that contains more info about the kind of error that occurred

We need to add a match in order to use Result here is one option

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {error:?}"),
    };
}

This is like the Option enum Result enum and its variants have been brought into scope by the prelude, so you dont need to specifiy Result:: before the Ok and Err variants in the match arm

When the result is Ok the code will reutrn the file value out itself and then we can assign that file handle to the greeting_file. After machh we can use the file for reading or writing.

The other arm in match handles the Err value we get from File::open, in this case we choose to call the panic! macro with more details about the error

Matching on Different Errors

You can take different actions depending on the type of failure

For example if a file doesnt open because it doesnt exist then maybe you want you want to create the file first instead of panicing and exiting the program

One way we can do this is by using a match for the returned value Result<T, E>

Example

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {e:?}"),
            },
            other_error => {
                panic!("Problem opening the file: {other_error:?}");
            }
        },
    };
}

The tpye that ``File::openreutrn inside thrErrvarian isio:Error`, this is a struct provided by the std library

This struct has a method kind which can be called to get an io::ErrorKind value

The enum io::ErrorKind is also provided by a std library which has vairants representing the different kinds of errors that may result from an io operation

The error we care about is Error::NotFound, this indicates that the file that we try to open doesnt exist.

In this case we need to have a match on the outher thne use that on an inner match on error.kind()

In this inner match we create a file which can also fail hence in 2nd inner match to each return a file handle or panic out of the program

Alternatives to Using match with Result<T, E>

this match expression is very useful but also primitive and boilerplate

one example to condese this code is

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {error:?}");
            })
        } else {
            panic!("Problem opening the file: {error:?}");
        }
    });
}

This accomplishes the same thing as the the example above but it uses if and else and the unwrap_or_else

this is much cleaner to read even though it odesnt use and match statements

look at the exact definition unwrap_or_else in the std library document

Shortcuts for Panic on Error: unwrap and expect

match works well, but it can be a bit verbose and doesnt communicate clearly what the intent is

Result<T, E> has many helper methods to do specific tasks

The unwrap method that is a shortcut for the match expression that was used before

If the Result value is a Ok variant, unwrap will return the value inside the Ok

If the Result is the Err variant, unwrap will call the panc! macro for us

Here is an example of its use

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

If this code without a hello.txt it will panic with an error like this

thread 'main' panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

You can also use the expect method lets us also choose the panic! error message

This is good for providing clearer error mesages and make tracking down the source of the panic easier

Here is an example of an expect in use

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

expect is used in the same way as unwrap to retunr the file handle or call the panic! macro

The message is used by expect in its call to panic! as the paramter that is passed to expect rather than a defualt message

expect is maninly used in production-quality code over unwrap becasue it gives more context about why the opertaion is always expected to succeed

Propagating Errors

When a operation fails you may want to pass back the error instead of handling the error within the function itself

This is used when you want the code that calls it to handle the error

here is an example is of this

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}

This could be written in an even smaller way, but this is good for exploration

For example is has a return type of Result<String, io::Error> this is also a vaule of Result<T, E> where the values are filled in with String and io:Error, also note thta the ownership will transfer

If the function succeeds without any problems then the cod that calls it will receive an Ok value that holds a String - the username that this function read from the file

If this function encounters any problems then the code that calls this will recive an Err value that holds an instance of io::Error that contains more info aobut what the problems were.

io:Error as the return type was chosne becuase that happens to be the tpye of the error value returned from both operations we're callin in this function's body that might fail these functions that could fail are File::open and read_to_string method.

the last term does not need the return keyword do to it being both the last item and a expression

its up to the calling code how to hanlde the Err and Ok value, for example it could call a panic! if it gets a Err value

This pattern of propagting errors is so commin in Rust that Rust provides the question mark operator ? to make it easier

A Shorcut for Popagation Errors: the ? Operator

Here is an implementation that is a bit smaller but deos the same thing as above

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}

The ? is placed adter a Result value is defined in almost the same way as the match expressions were dfeined in the previous example

If the Result value is an Ok, the value inside the Ok will get returned form this expression and it will continue

If the value is an Err, the Err will be returned from the whole function as if we had used the return keyword so that the error value gets propagated to te calling code

The difference between what match and what ? does is that error values that have thr ? operator called on them go through the from function, defined in thr From trait in the std library which is used to convert values fomr one type to another.

When ? operator calls the from function, the error type is received is converted into the error type defined in the return type of the current function

This is useful when a function returns one error type to represent all the ways a functon might fail, even if parts might fail for many different reasons

In the context of the example, the ? at the end of the File::open call will return the value inside an Ok to the variable useranme_file

If an error occurs the ? operator will retunr early out of the whole function and give any Err value to the calling code.

This same thing applies to the ? at the end of the read_to_string call

The ? operartor eliminates a lot of boilerplate and make this function's implementation simpler

Here is an even shorter version of this code

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}

Instead of creating a veriable username_file it has been chained to the call to read_to_string directly from the result of File::open("hello.txt")?

We still need a ? at the end of read_to_string call, which still allows it to return an Ok value containg username when both File::open and read_to_string succeed rather than returning errors

The functionality is still the same as the two examples above

Here is an even shorter veriosn ising fs::read_to_string

use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}

Reading a file into a string is so common that the std library provides the convenient fs::read_to_string function that opens a file, creates a new String, reads the contents of the file which puts the contents into that String, and returns it

But this doesnt show how to explain ho wto use it

Where The ? Operator Can Be Used

The ? operator can only be used in functions whose return type is compatible with the value in the ? is used on

Because the ? is defined to perform an early return of a value out of the function, in the same manner as the match expression.

In the match case there was an arm that retunred an Err(e) value that is compatible with its return

You can use ? operator in main due to another crate being able to be used in a different crate

This requires that the retun type is compatable, () is not compatable with Result

For example this is not compatable

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}

But it will outbut something like this

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let greeting_file = File::open("hello.txt")?;
  |                                                ^ cannot use the `?` operator in a function that returns `()`
  |
  = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
help: consider adding return type
  |
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 |     let greeting_file = File::open("hello.txt")?;
5 + 
6 +     Ok(())
7 + }
  |

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` (bin "error-handling") due to 1 previous error

If you notice this states that you are only allowed to use ? if it returns Result, Option or another type that implements FromResidual

To fix this error we have two options:

  1. Change your return tpye of your function to be compatible with the value yo're using the ? operator on, so long as there are no other restrictions preventing this
  2. Use a match or one of the Result<T, E> methos to handle the Result<T, E> in the may most appropriate

The eror message also mentioned that ? can be used with the Option<T> values as well