How to Design For Panic Resilience in Rust | by Luke Wilson | Towards Data Science

Try to imagine using some software and WHAM, a bunch of text, and visual garbage the brain can’t help but ignore. Or better yet, your user is piloting a commercial airliner when they press two buttons at the same time and the plane turns off. What we need is clear feedback, and to prevent halting execution. In this story, we discuss methods for panic resilience in Rust applications, to make quality software users can rely upon.
If you consider your software a car driving at about 60 miles per hour, a panic is like hitting a brick wall.
In 1990, a software update caused all 114 of AT&T’s nationwide Electronic Switching Systems to fail. When a tower failed, it would send a message to its neighboring towers that it was halting traffic. The message received by other towers caused them to enter a similar fail-state, perpetuating the message through the entire AT&T long-distance network.1
Poor logic is the cause of most harmful software bugs.2 Faults may be more discreet in C because ignored errors do not create warnings. Rust could have prevented some nasty software bugs that have happened in the past, but only if software developers make use of the vital error handling logic provided by the Rust language.
In the C language, number values are the idiomatic method of conveying “an error has occurred”. A function call returns an integer representing an error code. If the code is zero, then no error occurred. Otherwise, the code can be compared against values to determine what fault occurred.
The issue with that method of error handling in C is that it is not an error nor warning to ignore the return value of function calls. Failures may go unheard and unhandled.
The number one goal of error handling is to prevent crashes.
Some newer languages designed after C use exceptions for error handling, which is a high-level abstraction to error codes. Calling a function which may fail and cause an exception requires a try and catch block — to execute code that may cause an exception, and to handle the error signaled by the exception. Exceptions are still not always explicitly handled, and thus some lazy programmers handle exceptions the same way as .unwrap() handles errors in Rust — print the error and fail.
In Rust, errors are explicit. Errors in Rust are, by idiom, Result and Option types. Because errors are explicit, programmers get tired of dealing with them and eventually prefer to .unwrap() every one, which does nothing in the name of panic resilience. Unwrap encourages “best hopes” and blindly dives into whatever data may exist, and if at any case it fails, it’s a hard crash — a panic.
The number one goal of error handling is to prevent crashes.
Let’s look at three crucial sections about handling errors in Rust:
When to Panic,Handling Errors,And the additional section: Error Handling in a Library.
When to Panic,
Handling Errors,
And the additional section: Error Handling in a Library.
It is always better to exit with an error code than to panic. In the best situation, no software you write will ever panic. A panic is a controlled crash, and must be avoided to build reliable software.
A crash is not ever “appropriate” behavior, but it’s better than allowing your system to cause physical damage. If at any time it may be believed that the software could cause something deadly, expensive, or destructive to happen, it’s probably best to shut it down.
If you consider your software a car driving at about 60 miles per hour, a panic is like hitting a brick wall.
A panic unwinds the call stack, hopping out of every function call and returning from program execution, destroying objects as it goes. It is not considered a safe nor clean shutdown. Avoid panics.
The best way to end a program’s execution is to allow it to run until the last closing brace. Somehow, some way, program for that behavior. It allows all objects to safely destroy themselves. See the Drop trait.
Panic is a last-resort, not a nifty built-in that makes it easy to exit with a message!
Result, Option, std::io::Result, and other some other types can represent the success of an operation. In idiomatic Rust, if something can fail, it returns a Result which encodes two possible values: either a success value (Ok) or an error value (Err). Result<T, E> where T is the success value type, and E is the error value type.
We match our Results against the success and error values. If it is a success then we just take our value and progress in our software. But if the value produces an error, we must have an effective backup plan based on these questions:
Can we try again?Is the data absolutely mandatory to proceed, or can it be generated, adjusted, or assumed?
Can we try again?
Is the data absolutely mandatory to proceed, or can it be generated, adjusted, or assumed?
If the data is required to proceed, then we need to bubble the error up to the caller.3 We absolutely cannot panic in any function but main. Otherwise, you’ve got self-entitled functions running around believing they’re god objects. Proper error handling does all it can, and when in doubt, lets the caller handle the issue.
The following example applies the first question: Can we try again?
fn open_config() -> Result<std::fs::File, std::io::Error> {    use std::fs::File;    match File::open("config.toml") {        Ok(f) => Ok(f),        // If not found, search in second location:        Err(e) => match File::open("data/config.toml") {            Ok(f) => Ok(f),            // Otherwise, bubble first error up to caller            _ => Err(e),        }    }}fn main() {    let mut result = open_config();    if result.is_err() { // If failed the first time        // Try again in about 5 seconds ...        std::thread::sleep(std::time::Duration::from_secs(5));        // Reattempt        result = open_config();    }    match result {        // Proceed as usual ...        Ok(cfg) => println!("Opened the config file"),        // Print the cause to stderr, and DONT PANIC        Err(e) => {            eprintln!("File could not be opened: {:?}", e.kind());            std::process::exit(1); // Exit with code 1 (fail)    }}
This code attempts to find a config file at “config.toml”. If it fails, it attempts to find the same configuration file in a specific subfolder. Only once the second attempt fails does it bubble the error up to the caller. The caller is main, and it is willing to reattempt a failure the first time after a 5 second pause. Notice how even main is not going to panic because while this is a fatal error, it is possible to resume the normal exiting process. It is always better to exit with an error code than to panic.
But same with panics, std::process:exit can prevent the destruction of objects, so only use it while nothing requiring the Drop trait is still owned. So panic is a last-resort, not a nifty built-in that makes it easy to exit with a message!
This last example applies the second question: Is the data absolutely mandatory to proceed?
/// Get the username from the config if it is present.fn read_username_from_config() -> Option<String> {    // Implementation hidden}/// Get the username if present. Otherwise returns the/// default username "unnamed".fn get_username() -> String {    read_username_from_config()        .unwrap_or(String::from("unnamed"))}fn main() {    let username = get_username();    println!("Username is: {}", username);}
In the above example, main is only requesting a username. get_username returns String which means there is zero capacity for it to fail. It attempts to read a username from the config file using read_username_from_config, but if one cannot be retrieved, it uses the default string “unnamed”.
It is important to weigh the value of data you can fail to get. Consider whether it’s possible to return to the input loop, or resume with a warning. Stay concerned about the usability of your software and how well it can overcome lack of data.4
Enums are the way to go when representing error values as codes.
#[derive(Debug, PartialEq)] // Use derivesenum SafeDivideError { // Define a custom error type  DivideByZero,}fn safe_divide(dividend: i32, divisor: i32) -> Result<i32, SafeDivideError> {  if divisor == 0 { // If the dividend is zero ...    Err(SafeDivideError::DivideByZero) // Return the error value  } else { // Otherwise ...    Ok(dividend / divisor) // Return the success value  }}assert_eq!(safe_divide(4, 2), Ok(4/2));assert_eq!(safe_divide(4, 0), Err(SafeDivideError::DivideByZero));
In the above example, we use a custom enum as the error return type. This makes it easy to see what we produced the error for, but a divide by zero is the only reason we would need to report an error in this situation. So, the better type to use is likely an Option<i32>.
Creating custom error types is valuable. When you use a bare enum as an error type, the data footprint can be tiny. This matters because regardless of using the success or error value of a Result<i32, String>, for example, it consumes as much memory as its largest member. In this case, a String, which is 16 bytes larger than an i32 on the stack. On the contrary, the SafeDivideError enum consumes “zero” bytes of memory.
println!("String:          {}B", std::mem::size_of::<String>());println!("SafeDivideError: {}B", std::mem::size_of::<SafeDivideError>());// String:          24B// SafeDivideError: 0B
And when two or more values are used, it becomes the size of a u8 and can represent as much as 256 different error values, before gaining another byte. Enums are the way to go when representing error values as codes.
It is important to make use of documentation comments to explain why a function may return an error, and especially what different error values mean. If a function returns Result<i32, String> , then someone using your library would definitely want to know what different Strings can be returned by a failure of the function.
The older rules still apply to creating a library: never panic and allow the caller of the function to handle errors only when you cannot — bubble errors when necessary. Designing a good error resilient library requires putting emphasis on robust error types and their documentation. Take some genius from std::io::Error.
Rust empowers anyone to build highly robust, reliable, and efficient software. So don’t panic! Use the correct methods for writing reliable software with Rust.
Source: http://www.phworld.org/history/attcrash.htmThere’s a really good list of them: https://en.wikipedia.org/wiki/List_of_software_bugsError bubbling is when code passes an error upstream, because it decidedly won’t handle it. AKA error propagation.But don’t perform silent data manipulation.
Source: http://www.phworld.org/history/attcrash.htm
There’s a really good list of them: https://en.wikipedia.org/wiki/List_of_software_bugs
Error bubbling is when code passes an error upstream, because it decidedly won’t handle it. AKA error propagation.
But don’t perform silent data manipulation.
Some rights reserved