Skip to content

Error Handling & Testing

Rust classifies errors as either recoverable or unrecoverable. Recoverable errors include things like “file not found” while unrecoverable errors are considered bugs such as out-of-bounds array access. Instead of using things like exceptions, Rust uses the panic! macro for unrecoverable errors, and a generic type Result<T, E> for recoverable errors. Some errors are handled simply by design because Rust is strongly typed. For example, using types themselves guarantees that values are valid given a specified type, or that there are indeed values at all. These issues are all caught by the compiler before the code can even be run. For the rest, lets look at the mechanisms Rust employs.

Panic!

The panic! macro shuts shit down… sorta. That is to say that when a program panics it immediately returns. You can invoke the panic! macro either implicitly when a bug is encountered or explicitly in code. In both cases, by default, these panics will print a failure message, unwind (which is a fancy way to say that Rust walks back up the stack and forces each function to return from each function it encounters) and quits… so long as you dont have any unsafe code that gets trapped in an infinite loop while unwinding. For that reason make sure anything that can panic is at the start or end of your methods, where invariants should be known to hold. Its also possible to display the call stack (backtrace) when the panic occurs to make bug identification easier. To make the program as small as possible and avoid the big work of unwinding you can abort instead. This involves setting the panic profile in the Cargo.toml file.

[profile.release]
panic = 'abort'

For the following examples the panic will unwind. To explicitly call a panic we can simply use the panic! macro.

fn panic_mode() {
panic!("Failure message");
}

Run from the main.rs file, this produces the following message that details the file that the error originated in, which is not the main.rs file! The error message is helpful because it includes the file name, line, and column that the error occurred in. In this case the macro is indented so the error points to the first character of the panic! macro on the 2nd line in the 5th column of the file.

thread 'main' panicked at src/cncpt/error_handling.rs:2:5:
Failure message
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Backtrace

The backtrace is a list of all functions that have been called to get to the designated point. The way to read this is to start from the top and stop at the lines we wrote. Calling the same function listed above with backtrace looks something like this.

$ RUST_BACKTRACE=1 cargo run
Compiling learning_rust v0.1.0 (/Users/me/learning_rust)
Finished dev [unoptimized + debuginfo] target(s) in 0.67s
Running `target/debug/learning_rust`
thread 'main' panicked at src/cncpt/error_handling.rs:2:5:
Failure message
stack backtrace:
0: rust_begin_unwind
at /rustc/cc66ad468955717ab92600c770da8c1601a4ff33/library/std/src/panicking.rs:595:5
1: core::panicking::panic_fmt
at /rustc/cc66ad468955717ab92600c770da8c1601a4ff33/library/core/src/panicking.rs:67:14
2: learning_rust::cncpt::error_handling::panic_mode
at ./src/cncpt/error_handling.rs:2:5
3: learning_rust::main
at ./src/main.rs:103:5
4: core::ops::function::FnOnce::call_once
at /rustc/cc66ad468955717ab92600c770da8c1601a4ff33/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

To read this think of a chronology where the last item listed was the first to happen. So in this scenario the 4th listing details a core language call at line 250 of a function.rs file. This file defines call operators in Rust. The next call is where the language encountered the initial function call to our panic function in the main.rs file. Note that in this scenario our panic function is actually defined in a module called error_handling and not in the main.rs file itself. The next call lists the actual panic function we defined in our separate error_handling module called panic_mode. It may be helpful to actually read this backwards though, which is why it is listed in reverse-chronological order. We can start with the result and trace the processes backwards until we get to our code.

If we want more, we can follow the note in the trace to run the same operation with RUST_BACKTRADE=full.

Handling Result<T, E>

The Result<T, E> type is an enum contained within the standard prelude. The Result<T, E> type has two variants. The Ok variant represents the happy path and returns the contents of the process. The Err variant returns an error from the OS (or custom user-defined errors).

enum Result<T, E> {
Ok(T), //The type that is returned upon success
Err(E), //The error that is returned if encountered
}

Result<T, E> may be returned by functions to ensure proper error handling. For example, one of the functions in the process of accessing a file (std::fs::File::open()) returns a Result<T, E> type. This is helpful because the desired file may not exist, or we may not have proper access permissions, both of which would cause an error and a potential program crash without proper handling. Those kinds of scenarios are hardly worth exiting the program over. We actually have a couple different options to handle Result<T, E> return types. These include using match expressions, method implementations on the base enum including unwrap().

With match

Perhaps the most common way to handle Result<T, E> types is when we are unable to predict the result. It is possible to handle the Result<T, E> return enum using the match mechanism similar to how the Option type is handled. Remember that all match arms must return the same type! To handle a Result<T, E> type return, lets look at an example.

We can first create and bind a variable to the returned Result<T, E> type (process_result). The File::open() function returns a file handle, which is an abstract reference to the file, not the file or its contents itself. This means we can’t bind the returned value of File::open() directly to a String yet. These two steps are illustrated in the following code.

let process_result = File::open("hello_world.txt"); //1 Binds a variable to open()'s Result
let file_handler = match process_result { //2 Uses match to bind the file handle or process errors
Ok(file) => file, //Accesses file handle, but doesn't actually do anything with the file!
Err(error) => panic!("Problem opening the file: {:?}", error),
};

It is possible to stack tiers of match mechanisms for more precise error handling. For example, our example panics when it encounters ANY error at all. To narrow this down we can add a match arm with the kind() error implemented for std::io::Error struct. The kind() method matches on the ErrorKind enum which has 40 variants at the time of this writing. Let’s say we want to create a file if a file is not found. There is an ErrorKind variant called NotFound that we can use to implement this behavior. This example expands on the last example to create a new file if the hello_world.txt file is not found. The kind() method forces exhaustive match behavior so we need a catch-all for all the other errors we’re not handling explicitly. To do this, we could use the _ unit type as generic catch-all, but because all match arms must return the same type, and our happy path returns a File type, we must write our catch-all to also return a File type… or panic.

let process_result = File::open("hello_world.txt");
let file_handler = match process_result {
Ok(file) => file,
//Matches against a defined ErrorKind enum variant
//with the kind() method for the Error struct
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create(&title) {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {:?}", e),
},
//Catch-all as named identifier;
other_error => panic!("Error: {:?}", other_error),
},
};

The println! macro returns the unit type () so we cannot simply print the other_error. We can write some hacky bs to satiate the compiler though. Instead of panicking we could write something like the following.

other_error => {
println!("{:?}", other_error);
File::open(" ").expect(" ")
}

With methods that panic

We’ve established that match is the fundamental methodology when a type might return an error. This is verbose, so Rust includes several convenience methods. Specifically, the Result<T, E> enum has some handy implementations to keep our code concise and readable. The first method worth mentioning is the unwrap() method. This method works very similarly to the code we’ve already written for handling the Result<T, E> type. The unwrap() method uses a match expression and returns the inner value for an Ok and panics with an Err.

let greeting_file = File::open("hello.txt").unwrap();

Similarly, the expect() method does the same thing, except it allows us to supply a custom message upon encountering an Err arm match.

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

NOTE: While using the expect() method is generally preferred over the unwrap() method, both methods are only really appropriate for examples and prototype code versus more robust production code. The official Rust documentation actually discourages use of both of these methods (in production code) due to potentially unnecessary panic behavior from both of these methods. In production code, consider using methods like match, if let, or the ? operator for more controlled and explicit error handling. The unwrap(), expect() can serve as clear markers for improvements!

With methods that dont panic

Additionally, we can use unwrap_or_else() to define a custom non-panic error handling. This works similarly to unwrap() but calls the code in its closure if it encounters an error. Closures are anonymous functions designed to pass an argument to the calling function, in this case the err value passed to unwrap_or_else(). The process::exit function stops the program immediately and returns the number passed as the exit status code. This is similar to the panic!-based handling of unwrap() or expect(). Adding a nonzero process::exit() status is a convention to tell the calling function that the program exited with an error state. Calling the process::exit() doesn’t print out all the stack wind-down information that panic functions do.

use std::process;
...
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
let file = function_returns_result().unwrap_or_else(|e| format!("Error: {}", e));
println!("File contents: {}", file);

With if

It is also possible to impose rules on our Result<T, E> type by using conditional if statements. This may be necessary when any number of possibilities passed to our function is legal but not necessarily desired. These types of situations often come up with usability concerns.

For example, if our program takes X number of arguments and we want to inform the user, we can write conditional error logic. This function takes an array of String reference arguments and builds a struct called Config. The argument ensures that the user enters precisely three arguments and returns customized error messages.

fn build(args: &[String]) -> Result<Config, &'static str> {
// Checks for 3 arguments because args() inherently captures progname as the first
if args.len() < 3 {
return Err("Insufficient arugments; need query and file path");
}
if args.len() > 3 {
return Err("Too many arguments; need query and file path");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}

With Boolean values

If we run into a situation where we want to check if the type returned by a Result<T, E> type is present and legal, such as checking whether an environment variable is present, we can use Result’s is_ok() method.

let x: Result<i32, &str> = Ok(-3);
assert_eq!(x.is_ok(), true);
let ignore_case = env::var("IGNORE_CASE").is_ok();

The Result enum has lots of implementations; collect them all!

Propagating errors

It may be desirable in real-world use cases to write a function that simply returns a Result<T, E> type rather than to handle the error directly. This is because functions should be atomic and modular, and we dont always know the context of the situation. For example, if we’re writing a function to retrieve information from a file and we encounter an error, there may be other code that we want to list as part of the calling workflow rather than the function itself. This could be calling a different file, or database, or inserting a generic value, or any host of other logical workflow steps. The concept of writing functions that return the Result<T, E> type is called propagating errors because it allows the calling code to handle the error.

With Result<String, io::Error>

The following example illustrates error propagation. The function returns a Result<T, E> type and defines the generic types for concrete String and io::Error types. Generics are covered elsewhere, but for now just know that this also requires a use statement that includes std::io::self for the concrete error type usage. The function contains two match statements. The first one handles the Result<File> type returned by the open() method, and the second one handles the Result<T, E> type returned by the std::fs::read_to_string() function which returns a Result<String> type.

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),
}
}

The ? operator

Propagating errors, as you might imagine from the description at the top of this section, is so common that Rust includes special syntax to write short, concise code to accomplish it. The ? operator is used after an expression that returns a Result<T, E> type. This is done in lieu of a match statement and signals the code to pass either the Ok or Err values as the function’s return values. The following example does the same thing as the previous example, but without all the boilerplate. Notice that its essentially the same code without the match statements. The ? operator uses the from() function which converts potential errors to be converted to the type defined in the current function.

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)
}

We can even shorten this further by chaining method calls after the ? operator. The ? operator is used in functions that return a type that implements FromResidual, and Try traits such as the Result or Option types. The operator is used to return values early if/when they are encountered. Instead of creating and binding a username_file Result<T, E> type and then using it to call read_to_string(), we can simply bind it inline. If the file exists and can be read, the function returns the Ok value (in this case username), and if there is an error on either the open() or read_to_string() functions, the error is returned early.

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)
}

Or we could just cut to the chase and make it as short as possible. Note that this is the same function we’ve called before, but we need to specify the originating module explicitly as the calling object.

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

We can then call this function with something like the following example located in a hypothetical module named module.

match module::read_username_from_file() {
Ok(result) => println!("Username: {}", result),
Err(e) => println!("Error: {}", e),
};

With Result<(), Box<dyn Error>>

It is not uncommon to propagate errors with Result<(), Box<dyn Error>>. This requires the use std::error::Error import. Boxed dynamic errors can be useful when we dont know the error type.

More than one Error definition

You may find yourself mixing and matching error handling approaches. You may run into situations where one function handles an error with std::io::Error and another that handles an error with std::error::Error. It is not possible to have two types with the same definition in the same module. We can get around this by aliasing one (or both) Error imports with the as keyword.

use std::io::Error as IoError;
use std::error::Error as StdError;

General Usage

The decision to use panic! (or a function/method that implements it) or whether to propagate the error to the calling code using Result is up to you. Writing functions that return Result types is generally recommended (for robust production code) because it allows for more options in how to handle errors. Some situations benefit from code that panics or handles errors directly. Prototyping, examples, and tests are just a few situations in which it may be more desirable to write code that panics. For example, it may be beneficial to write tests that panic.

Error streams

There are two primary output streams in Rust; stdout (standard output) and stderr (standard error). Choosing between output stream types allows us to effectively direct successful output and error output. This is mostly used to direct successful output to file and error output to the console. The print macro println!() uses stdout. This makes it undesirable if we want to direct error messages to console. Rust provides the eprintln!() macro which uses the stderr output stream to write error messages to console where they are the most useful.

let config = Config::build(&args).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
eprintln!("Application error: {e}");
process::exit(1);
};

Exit code

What’s that process::exit(1) line? Its an exit code. A nonzero exit status is a convention to signal to the process that called our program that the program exited with an error state.

Examples

Panicking is easy. Handling errors gracefully while continuing the program is a little more difficult. Here is a selection of error handling patterns that do not panic. Generally speaking, match is the simplest, and unwrap_or_else() is the most elegant, but all approaches have their place.

Example 1: match

This example illustrates a basic match mechanism used on a string type (from a file I/O operation). We use the format!() macro for the error arm because the println!() macro returns a unit () type and both match arms must be of the same type. The format!() macro returns a String which solves this issue.

let file_contents = match std::fs::read_to_string("./files/hello_world.txt") {
Ok(file) => file,
Err(e) => format!("Error: {}", e),
};
println!("{}", file_contents);

Example 2: match

This does the same thing as above, but prints the error message from within the Err arm. We use the return keyword to exit the function early to prevent any further errors from occurring in the parent function. Alternatively we could use process::exit(1) to shut the whole program down upon encountering an error.

pub fn error_handling() {
let file_contents = match std::fs::read_to_string("./files/hello_world.txt") {
Ok(file) => file,
Err(e) => {
let error_message = format!("Error: {}", e);
println!("Error: \n\t{}\n", error_message);
return //avoid further code in the function
//process::exit(1) //Shut the whole program down!
},
};
println!("Unreachable with an error");
}

Example 3: if let Ok

We dont have to use match to handle Result<T, E> types. This example uses the if let syntax to create a variable named file_contents. The major downside of this mechanism is that it doesn’t allow us to access the error message from the Result<T, E> type.

let mut file_contents = String::new();
if let Ok(file) = std::fs::read_to_string("./files/hello_world.txt") {
//file_contents.push_str(&file) //Requires allocation and copying, which may be slower
file_contents = file;
} else {
println!("Error: [custom error message]\n");
return //avoid further code in the function
//process::exit(1) //Shut the whole program down!
};
println!("Unreachable with an error:\n\t{}", file_contents);

Example 4: propagating type values

We dont necessarily have to deal with the Result<T, E> in the immediate function if we set it as a return type.

pub fn error_handling_6() -> Result<String, io::Error> {
fs::read_to_string("./files/hello_word.txt")
}

This requires the calling code to handle the type with something like the previous examples. This example uses a simple match similar to Example 1.

let file = match cncpt::error_handling::error_handling_6() {
Ok(file) => file,
Err(e) => format!("Error: {}", e),
};
println!("File contents: {}", file);

Or we could do it a little more elegantly with unwrap_or_else().

let file = cncpt::error_handling::error_handling_6().unwrap_or_else(|e| format!("Error: {}", e));
println!("File contents: {}", file);

Example 5: propagating the unit type

If we want to propagate a success without passing a particular value we can bubble up the unit () type. Instead of tacking on individual unwrap_or_else() functions or specific matches, we can use the ? operator and a generic std::io::Result<()> signature with a Ok(()) return statement to bubble all errors up.

pub fn error_handling_8() -> std::io::Result<()> {
let mut file = File::open("hello.txt")?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(())
}

This can be handled in a handful of ways, but its common to use the unit type () from the calling code.

match cncpt::error_handling::error_handling_8() {
Ok(()) => println!("It works!"),
Err(ref e) => eprintln!("Error: {}", e),
};
OR
if let Ok(()) = cncpt::error_handling::error_handling_8() {
println!("Success!")
} else {
println!("An error occurred...")
}

If we tried to get value we’d end up with a unit type upon success.

//Doesn't propagate values
let result = match cncpt::error_handling::error_handling_8() {
Ok(()) => println!("File read successfully."),
Err(ref e) => eprintln!("Error: {}", e),
};
println!("The result is: {:#?}", result);
The result is: ()