Rust Recoverable Errors
Error handling is a critical aspect of writing robust software. Rust has a unique approach to error handling that helps you write more reliable code by forcing you to handle potential failures explicitly. In this tutorial, we'll explore how Rust handles recoverable errors.
Introduction to Recoverable Errors
In Rust, a recoverable error is an error condition that shouldn't necessarily terminate the program. Instead, it's an error that you might want to report to the user and retry the operation, or handle in some other way.
For example, if a file you're trying to open doesn't exist, that's a recoverable error - you might want to create the file instead, or prompt the user for a different filename.
Rust uses the Result<T, E>
enum to handle recoverable errors, which is defined as:
enum Result<T, E> {
Ok(T),
Err(E),
}
Where:
T
is the type of the value returned in the success caseE
is the type of the error returned in the failure case
Basic Error Handling with Result
Let's start with a simple example - opening a file:
use std::fs::File;
fn main() {
let file_result = File::open("hello.txt");
// The result needs to be handled
match file_result {
Ok(file) => println!("File opened successfully: {:?}", file),
Err(error) => println!("Failed to open the file: {:?}", error),
}
}
Output (if file doesn't exist):
Failed to open the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
In this example:
- We attempt to open a file with
File::open("hello.txt")
- This returns a
Result<File, std::io::Error>
- We use a
match
expression to handle both possibilities
Handling Different Error Types
We can match on specific error types to provide more tailored error handling:
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let file_result = File::open("hello.txt");
let file = match file_result {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => {
println!("Created new file!");
fc
},
Err(e) => panic!("Problem creating the file: {:?}", e),
},
other_error => {
panic!("Problem opening the file: {:?}", other_error);
}
},
};
println!("File handle: {:?}", file);
}
Output (if file doesn't exist):
Created new file!
File handle: File { fd: 3, path: "/hello.txt", read: true, write: true }
This example demonstrates more sophisticated error handling:
- If the file doesn't exist, we create it
- If there's a different kind of error, we panic
- If opening succeeds, we use the file
Shortcuts for Error Handling
The unwrap
Method
The unwrap
method is a shortcut for the match expression. It returns the value inside Ok
if successful, and calls panic!
if an error occurs:
use std::fs::File;
fn main() {
let file = File::open("hello.txt").unwrap();
println!("File: {:?}", file);
}
If "hello.txt" doesn't exist, this code will panic with a message like:
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:4:37
The expect
Method
Similar to unwrap
, but lets you specify an error message:
use std::fs::File;
fn main() {
let file = File::open("hello.txt")
.expect("Failed to open hello.txt");
println!("File: {:?}", file);
}
If an error occurs, the panic message will include your custom message:
thread 'main' panicked at 'Failed to open hello.txt: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:5:10
Propagating Errors
In functions that might fail, it's common to return a Result
so the calling code can decide how to handle any errors:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let username_file_result = File::open("username.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),
}
}
fn main() {
match read_username_from_file() {
Ok(username) => println!("Username: {}", username),
Err(error) => println!("Error reading username: {:?}", error),
}
}
Output (if file doesn't exist):
Error reading username: Os { code: 2, kind: NotFound, message: "No such file or directory" }
The ?
Operator
The code above can be simplified with the ?
operator, which is a shorthand for the entire error-handling match expression:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username_file = File::open("username.txt")?;
let mut username = String::new();
username_file.read_to_string(&mut username)?;
Ok(username)
}
// This can be further simplified to:
fn read_username_even_shorter() -> Result<String, io::Error> {
let mut username = String::new();
File::open("username.txt")?.read_to_string(&mut username)?;
Ok(username)
}
// And even shorter using the fs module:
fn read_username_shortest() -> Result<String, io::Error> {
std::fs::read_to_string("username.txt")
}
fn main() {
match read_username_from_file() {
Ok(username) => println!("Username: {}", username),
Err(error) => println!("Error reading username: {:?}", error),
}
}
The ?
operator:
- Works on
Result
values - If the value is
Ok
, it unwraps the value (likeunwrap()
) - If the value is
Err
, it returns the error from the current function - Can only be used in functions that return a compatible
Result
type
Flow of Error Handling with the ?
Operator
Using ?
in main
Since Rust 2018, the main
function can return a Result
:
use std::fs::File;
use std::io;
fn main() -> Result<(), io::Error> {
let mut file = File::open("hello.txt")?;
// Do something with the file
Ok(())
}
Creating Custom Error Types
For larger applications, it's common to define your own error types:
#[derive(Debug)]
enum AppError {
FileError(std::io::Error),
ParseError(String),
InvalidInput(String),
}
// Implement conversion from std::io::Error to our AppError
impl From<std::io::Error> for AppError {
fn from(error: std::io::Error) -> Self {
AppError::FileError(error)
}
}
fn read_config_file(path: &str) -> Result<String, AppError> {
// The ? will automatically convert io::Error to AppError
let config_text = std::fs::read_to_string(path)?;
if config_text.is_empty() {
return Err(AppError::ParseError("Config file is empty".to_string()));
}
Ok(config_text)
}
fn main() {
match read_config_file("config.txt") {
Ok(config) => println!("Config content: {}", config),
Err(error) => match error {
AppError::FileError(e) => println!("File error: {}", e),
AppError::ParseError(msg) => println!("Parse error: {}", msg),
AppError::InvalidInput(msg) => println!("Invalid input: {}", msg),
},
}
}
Real-World Example: A Command-Line Tool
Let's look at a more complete example - a simple command-line tool that reads a configuration file:
use std::env;
use std::fs;
use std::io;
use std::process;
#[derive(Debug)]
enum ConfigError {
IoError(io::Error),
MissingValue(String),
InvalidValue(String),
}
impl From<io::Error> for ConfigError {
fn from(error: io::Error) -> Self {
ConfigError::IoError(error)
}
}
struct Config {
database_url: String,
port: u16,
}
fn read_config(path: &str) -> Result<Config, ConfigError> {
let content = fs::read_to_string(path)?;
let mut database_url = None;
let mut port = None;
for line in content.lines() {
if line.trim().is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.splitn(2, '=').collect();
if parts.len() != 2 {
continue;
}
let key = parts[0].trim();
let value = parts[1].trim();
match key {
"database_url" => database_url = Some(value.to_string()),
"port" => {
port = match value.parse::<u16>() {
Ok(p) => Some(p),
Err(_) => return Err(ConfigError::InvalidValue(
format!("Invalid port number: {}", value)
)),
}
},
_ => {} // Ignore unknown keys
}
}
let database_url = database_url.ok_or_else(||
ConfigError::MissingValue("database_url not specified".to_string())
)?;
let port = port.ok_or_else(||
ConfigError::MissingValue("port not specified".to_string())
)?;
Ok(Config { database_url, port })
}
fn run(config_path: &str) -> Result<(), ConfigError> {
let config = read_config(config_path)?;
println!("Starting server on port {} with database {}",
config.port, config.database_url);
// In a real application, we would start the server here
Ok(())
}
fn main() {
let args: Vec<String> = env::args().collect();
let config_path = args.get(1).unwrap_or(&String::from("config.txt"));
if let Err(error) = run(config_path) {
match error {
ConfigError::IoError(e) =>
eprintln!("Error reading config file: {}", e),
ConfigError::MissingValue(field) =>
eprintln!("Missing configuration value: {}", field),
ConfigError::InvalidValue(msg) =>
eprintln!("Invalid configuration value: {}", msg),
}
process::exit(1);
}
}
Example config.txt:
# Server configuration
database_url = postgres://user:password@localhost/mydb
port = 8080
Output (with valid config):
Starting server on port 8080 with database postgres://user:password@localhost/mydb
Output (with invalid port):
Invalid configuration value: Invalid port number: abc
This example demonstrates:
- Custom error types with variants for different error cases
- Using the
From
trait to convert between error types - Using the
?
operator for clean error propagation - Contextual error messages
- Proper error handling in the
main
function
Summary
Rust's approach to error handling through the Result
type offers several advantages:
- Explicitness: Errors are visible in function signatures
- Compile-time checking: The compiler ensures you handle all potential errors
- Composability: The
?
operator and combinators make working with errors ergonomic - Type safety: Error handling is type-checked
- Custom errors: You can define your own error types for domain-specific handling
By using Result
and related patterns, you can write code that gracefully handles failures instead of crashing.
Exercises
- Write a function that reads two files and concatenates their contents, returning a
Result<String, std::io::Error>
. - Modify the command-line tool example to add validation that the port number is within a valid range (e.g., 1024-65535).
- Create a custom error type for a program that needs to handle both file I/O errors and network errors.
Additional Resources
- Rust Book: Error Handling Chapter
- Rust By Example: Error Handling
- The
thiserror
crate - For deriving error types - The
anyhow
crate - For simplified error handling
Happy coding!
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)