Ideon Blog
April 01, 2020
By: Ideon
Using Rust to Speed Up Your Ruby Apps: Part 3 — Error Management
**Ideon is the company formerly known as Vericred. Vericred began operating as Ideon on May 18, 2022.**
In our last two blog posts we discussed the why and the how of using Rust to speed up Ruby apps. In this post we’re going to take a quick look at error handling between Ruby and Rust code.
Ruby Vs Rust Error Handling
Ruby, like most popular programming languages, use exceptions to indicate error conditions. You rescue (or catch in other languages) an exception to handle it. In Rust you have the panic! macro for unrecoverable errors and the Result type for recoverable errors. A key benefit of Rust’s handling of recoverable errors with Results is that you can easily see what errors a function may generate from the function’s signature. You are forced to deal with these errors in your code, even if that means propogating the error up with the ? operator or panicking with unwrap or expect. This approach of having the Rust compiler force you to address those issues at compile time means you’ll likely avoid many potential runtime bugs.
Here is a simple Ruby program that calls a method that calls Integer(String). If the string cannot be parsed to an integer an ArgumentError is thrown.
def main
double_val = parse_string_to_int('foo') * 2
puts "Result: #{double_val}"
end
def parse_string_to_int(value)
Integer(value)
end
If I pass the string “foo” to my parse_string_to_int
method it’s going to blow up with an ArgumentError
. But it’s hard to know this by just reading my code. I would have to have either read the documentation for Integer
, known to write a unit test that passes in an invalid value, or encountered the exception in prodcution when a user enters an invalid value.
Here is an example of a safe version in Ruby that catches an ArgumentError
, prints it to stdout and sets the value to -1.
def main
double_val = begin
parse_string_to_int('10') * 2
rescue ArgumentError => e
puts "Could not parse string. Error: #{e}, returning -1"
-1
end
puts "Result: #{double_val}"
end
def parse_string_to_int(value)
Integer(value)
end
Here is the same functionality in Rust.
fn main() {
let double_val: i32 = match parse_string_to_i32("foo") {
Ok(val) => val * 2,
Err(e) => {
println!("Could not parse string. Error: {}, returning -1", e);
-1
}
};
println!("Result: {}", double_val);
}
fn parse_string_to_i32(value: &str) -> Result<i32, std::num::ParseIntError> {
value.parse::<i32>()
}
The beauty of the Rust version is that the compiler will tell you right away that parse_string_to_i32
may generate an error and that you need to do something about it. The compiler doesn’t expect you to “just know”, or find out after your code has been released.
Returning Ruby Exceptions From Rust
As noted in our last blog post our Rust library takes advantage of parallelism outside of Ruby’s GVL. When operating outside of the GVL you can’t interact with any objects owned by your Ruby app. However, we may encounter errors in our Rust code that we ultimately want to handle in our Ruby code. To solve this problem we used the failure library to easily propagate our Rust errors up to a point where we could once again interact with the VM and generate a Ruby exception. Let’s take a look at an example. At this point we are going to assume a basic understanding of Rust and using Rutie for FFI.
Error management is a fluid situation in the Rust world at the moment and the best approach is still being debated. But the Failure library fit our fairly simple needs and was the best option at the time when we implemented our library. Failure makes it simple to define a new error type by using a derive macro and display attribute on a struct.
#[derive(Debug, Fail, PartialEq)]
#[fail(display = "Cannot calculate due to invalid input")]
pub struct InvalidInputRustError;
In our code that execute outside of the GVL we encounter a situation where we want to return our InvalidInputRustError type.
use failure::Error;
let calc_result: Result<(), Error> = Thread::call_without_gvl(
move || {
// do some work and then return an error
Err(InvalidInputRustError)
},
Some(|| {})
);
Now we are ready to return back to our Ruby code. We are going to match on our Result and if it’s an error we’re going to generate the appropriate Ruby error. We’ll also have a generic “catch all” exception to return. You’ll likely have implemented several different Fail types in your code. In order to figure out which type we’re dealing with we have to use Failure’s downcast function.
Using Rutie we can dynamically instantiate Ruby objects from our Rust code, including errors. We have an existing error type in a ruby module called Vericred::Errors::InvalidInput. We are going to create an instance of this error and then use the VM::raise command to raise the error in Ruby. Finally, we just return nil to Ruby because we have to return something.
match calc_result {
Ok(_) => // handle the happy path for our calculation,
Err(e) => {
match e.downcast::<InvalidInputRustError>() {
Ok(iie) => {
let invalid_input_error = Module::from_existing("Vericred")
.get_nested_module("Errors")
.const_get("InvalidInput")
.try_convert_to::<Class>()
.unwrap();
VM::raise(invalid_input_error, &format!("{}", iie));
RutieObject::from(NilClass::new())
}
Err(e) => {
// If none of the errors match the types we are concerned with
// create a new generic RustLibraryException class and raise it.
let runtime_error = Class::from_existing("RuntimeError");
Class::new("RustLibraryException", Some(&runtime_error));
let exception = AnyException::new("RustLibraryException", Some(&format!("{}", e)));
VM::raise_ex(exception);
RutieObject::from(NilClass::new())
}
}
}
}
Now your Ruby code can deal with the exception just like any other exception in Ruby. For example you can let this bubble up to your controller code and return a 422 http error.