Skip to main content

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:

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

Where:

  • T is the type of the value returned in the success case
  • E 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:

rust
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:

  1. We attempt to open a file with File::open("hello.txt")
  2. This returns a Result<File, std::io::Error>
  3. 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:

rust
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:

  1. If the file doesn't exist, we create it
  2. If there's a different kind of error, we panic
  3. 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:

rust
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:

rust
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:

rust
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:

rust
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:

  1. Works on Result values
  2. If the value is Ok, it unwraps the value (like unwrap())
  3. If the value is Err, it returns the error from the current function
  4. 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:

rust
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:

rust
#[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:

rust
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:

  1. Custom error types with variants for different error cases
  2. Using the From trait to convert between error types
  3. Using the ? operator for clean error propagation
  4. Contextual error messages
  5. Proper error handling in the main function

Summary

Rust's approach to error handling through the Result type offers several advantages:

  1. Explicitness: Errors are visible in function signatures
  2. Compile-time checking: The compiler ensures you handle all potential errors
  3. Composability: The ? operator and combinators make working with errors ergonomic
  4. Type safety: Error handling is type-checked
  5. 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

  1. Write a function that reads two files and concatenates their contents, returning a Result<String, std::io::Error>.
  2. Modify the command-line tool example to add validation that the port number is within a valid range (e.g., 1024-65535).
  3. Create a custom error type for a program that needs to handle both file I/O errors and network errors.

Additional Resources

Happy coding!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)