Skip to main content

Error Handling in Rust Ruby Extensions

This guide covers how to handle errors in Rust and map them to appropriate Ruby exceptions.

warning

Improper error handling can lead to crashes that take down the entire Ruby VM. Learn how to properly raise and handle exceptions in your Rust extensions.

Overview of Error Handling Approaches

When building Ruby extensions with Rust, you'll typically use one of these error handling patterns:

  1. Result-based error handling: Using Rust's Result<T, E> type to return errors
  2. Ruby exception raising: Converting Rust errors into Ruby exceptions
  3. Panic catching: Handling unexpected Rust panics and converting them to Ruby exceptions
note

In rb-sys extensions, Rust's Result<T, magnus::Error> type is typically used, where the Error type represents a Ruby exception.

The Result Type and Magnus::Error

Magnus uses Result&lt;T, Error&gt; as the standard way to handle errors. The Error type represents a Ruby exception that can be raised:

use magnus::{Error, Ruby};

fn might_fail(ruby: &Ruby, value: i64) -> Result<i64, Error> {
if value < 0 {
return Err(Error::new(
ruby.exception_arg_error(),
"Value must be positive"
));
}
Ok(value * 2)
}

The Error type:

  • Contains a reference to a Ruby exception class
  • Includes an error message
  • Can be created from an existing Ruby exception

Mapping Rust Errors to Ruby Exceptions

Standard Ruby Exception Types

Magnus provides access to all standard Ruby exception types:

use magnus::{Error, Ruby, RArray, Value};

fn divide(ruby: &Ruby, a: f64, b: f64) -> Result<f64, Error> {
if b == 0.0 {
return Err(Error::new(
ruby.exception_zero_div_error(),
"Division by zero"
));
}
Ok(a / b)
}

fn process_array(ruby: &Ruby, index: isize, array: RArray) -> Result<Value, Error> {
if index < 0 || index >= array.len() as isize {
return Err(Error::new(
ruby.exception_index_error(),
format!("Index {} out of bounds (0..{})", index, array.len() - 1)
));
}
array.entry(index)
}

fn parse_number(ruby: &Ruby, input: &str) -> Result<i64, Error> {
match input.parse::<i64>() {
Ok(num) => Ok(num),
Err(_) => Err(Error::new(
ruby.exception_arg_error(),
format!("Cannot parse '{}' as a number", input)
)),
}
}

Common Ruby exception types available through the Ruby API:

MethodException ClassTypical Use Case
ruby.exception_arg_error()ArgumentErrorInvalid argument value or type
ruby.exception_index_error()IndexErrorArray/string index out of bounds
ruby.exception_key_error()KeyErrorHash key not found
ruby.exception_name_error()NameErrorReference to undefined name
ruby.exception_no_memory_error()NoMemoryErrorMemory allocation failure
ruby.exception_not_imp_error()NotImplementedErrorFeature not implemented
ruby.exception_range_error()RangeErrorValue outside valid range
ruby.exception_regexp_error()RegexpErrorInvalid regular expression
ruby.exception_runtime_error()RuntimeErrorGeneral runtime error
ruby.exception_script_error()ScriptErrorProblem in script execution
ruby.exception_syntax_error()SyntaxErrorInvalid syntax
ruby.exception_type_error()TypeErrorType mismatch
ruby.exception_zero_div_error()ZeroDivisionErrorDivision by zero

Creating Custom Exception Classes

You can define custom exception classes for your extension:

use magnus::{Error, Ruby, exception};

#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
let module = ruby.define_module("MyExtension")?;

// Create custom exception classes
let custom_error = magnus::define_error("CustomError", exception::standard_error())?;
let validation_error = magnus::define_error("ValidationError", custom_error)?;

// Register them as constants for easier access
ruby.define_global_const("MY_CUSTOM_ERROR", custom_error)?;

Ok(())
}

// Using the custom exception
fn validate(ruby: &Ruby, value: &str) -> Result<(), Error> {
if value.is_empty() {
return Err(Error::new(
ruby.exception_standard_error(), // Use a standard error for now
"Validation failed: value cannot be empty"
));
}
Ok(())
}

Passing and Re-raising Ruby Exceptions

You can pass along existing Ruby exceptions:

use magnus::{Error, Ruby, Value};
use magnus::value::ReprValue;

fn process_data(ruby: &Ruby, input: Value) -> Result<Value, Error> {
// Call a method that might raise
let result = match input.funcall::<_, _, Value>("process", ()) {
Ok(val) => val,
Err(err) => return Err(err), // Pass along the original error
};

// Or with the ? operator
let result = input.funcall::<_, _, Value>("process", ())?;

Ok(result)
}

For wrapping and adding context to errors:

use magnus::{Error, Ruby, Value};

// Mock function for the example
fn complex_operation(ruby: &Ruby, input: Value) -> Result<Value, Error> {
// Placeholder implementation
Ok(input)
}

fn compute_with_context(ruby: &Ruby, input: Value) -> Result<Value, Error> {
match complex_operation(ruby, input) {
Ok(result) => Ok(result),
Err(err) => {
// Create a new error with additional context
Err(Error::new(
ruby.exception_runtime_error(),
format!("Computation failed: {:?}", err)
))
}
}
}

Handling Rust Panics

Rust panics should be caught and converted to Ruby exceptions to prevent crashing the Ruby VM:

use magnus::{Error, Ruby};
use std::panic::catch_unwind;

fn dangerous_operation(ruby: &Ruby, input: i64) -> Result<i64, Error> {
// Catch any potential panics
let result = catch_unwind(|| {
// Code that might panic
if input == 0 {
panic!("Unexpected zero value");
}
input * 2
});

match result {
Ok(value) => Ok(value),
Err(_) => Err(Error::new(
ruby.exception_runtime_error(),
"Internal error: Rust panic occurred"
)),
}
}

Error Handling Patterns

The Question Mark Operator

The ? operator simplifies error handling by automatically propagating errors:

use magnus::{Error, Ruby};

// Mock functions for the example
fn validate_input(ruby: &Ruby, value: i64) -> Result<i64, Error> {
if value < 0 {
Err(Error::new(ruby.exception_arg_error(), "Value must be positive"))
} else {
Ok(value)
}
}

fn transform_data(ruby: &Ruby, value: i64) -> Result<i64, Error> {
Ok(value * 2)
}

fn final_calculation(ruby: &Ruby, value: i64) -> Result<i64, Error> {
Ok(value + 10)
}

fn multi_step_operation(ruby: &Ruby, value: i64) -> Result<i64, Error> {
// Each operation can fail, ? will return early on error
let step1 = validate_input(ruby, value)?;
let step2 = transform_data(ruby, step1)?;
let step3 = final_calculation(ruby, step2)?;

Ok(step3)
}

Pattern Matching on Errors

For more sophisticated error handling, pattern match on error types:

use magnus::{Ruby, Value, Error};
use magnus::value::ReprValue;

fn handle_specific_errors(ruby: &Ruby, value: Value) -> Result<Value, Error> {
let result = value.funcall::<_, _, Value>("some_method", ());

match result {
Ok(val) => Ok(val),
Err(err) => {
// In Magnus 0.6, we can't use is_kind_of, so we handle all errors generically
// You could inspect the error message to determine type if needed
Err(err)
}
}
}

Context Managers / RAII Pattern

Cleanup with RAII
use magnus::{Error, Ruby, Value, RArray, value::ReprValue};
use std::fs::File;
use std::io::{self, Read};

struct TempResource {
data: Vec<u8>,
}

impl TempResource {
fn new() -> Self {
// Allocate resource
TempResource { data: Vec::new() }
}
}

impl Drop for TempResource {
fn drop(&mut self) {
// Clean up will happen automatically, even if an error occurs
println!("Cleaning up resource");
}
}

fn process_with_resource(ruby: &Ruby) -> Result<Value, Error> {
// Resource is created
let mut resource = TempResource::new();

// If an error occurs here, resource will still be cleaned up
let file_result = File::open("data.txt");
let mut file = match file_result {
Ok(f) => f,
Err(e) => return Err(Error::new(
ruby.exception_io_error(),
format!("Could not open file: {}", e)
)),
};

// Resource will be dropped at the end of this scope
let arr = RArray::new();
arr.push(1)?;
arr.push(2)?;
arr.push(3)?;
Ok(arr.as_value())
}

Best Practices for Error Handling

tip

These practices help create more robust extensions and improve user experience.

1. Be Specific with Exception Types

Choose the most appropriate Ruby exception type:

use magnus::{Error, Ruby};

fn example(ruby: &Ruby, index: usize, array: &[i32]) -> Result<i32, Error> {
// ✅ GOOD: Specific exception type
if index >= array.len() {
return Err(Error::new(
ruby.exception_index_error(),
format!("Index {} out of bounds (0..{})", index, array.len() - 1)
));
}

Ok(array[index])
}
note

Ruby has a rich hierarchy of exception types. Using the specific exception type helps users handle errors properly in their Ruby code.

2. Provide Clear Error Messages

Include relevant details in error messages:

fn dummy() {
// ✅ GOOD: Descriptive error with context
let err_msg = format!(
"Cannot parse '{}' as a number in range {}-{}",
"input", 0, 100
);

// ❌ BAD: Vague error message
let err_msg = "Invalid input";
}

3. Maintain Ruby Error Hierarchies

Respect Ruby's exception hierarchy:

use magnus::{Error, Ruby, Module, exception};

fn dummy(ruby: &Ruby) -> Result<(), Error> {
let module = ruby.define_module("MyExtension")?;
// ✅ GOOD: Proper exception hierarchy
let file_error = magnus::define_error("FileError", exception::io_error())?;
let format_error = magnus::define_error("FormatError", file_error)?;

// ❌ BAD: Improper exception hierarchy
let format_error = module.define_class("FormatError", ruby.class_object())?; // Not inheriting from StandardError
Ok(())
}

4. Avoid Panicking

Use Result instead of panic:

use magnus::{Error, Ruby};

// ✅ GOOD: Return Result for expected error conditions
fn process(ruby: &Ruby, value: i64) -> Result<i64, Error> {
if value < 0 {
return Err(Error::new(
ruby.exception_arg_error(),
"Value must be positive"
));
}
Ok(value * 2)
}

// ❌ BAD: Panicking on expected error condition
fn process_bad(value: i64) -> i64 {
if value < 0 {
panic!("Value must be positive"); // Will crash the Ruby VM!
}
value * 2
}

5. Catch All Ruby Exceptions

When calling Ruby methods, always handle exceptions:

use magnus::{Error, Ruby, Value, value::ReprValue};

fn catch_exception(ruby: &Ruby, obj: Value) -> Result<Value, Error> {
// ✅ GOOD: Catch exceptions from Ruby method calls
let result = match obj.funcall::<_, _, Value>("some_method", ()) {
Ok(val) => val,
Err(err) => {
// Handle or re-raise the error
return Err(err);
}
};
Ok(result)
}

fn unhandled_exception(ruby: &Ruby, obj: Value) {
// ❌ BAD: Not handling potential Ruby exceptions
let result: Value = obj.funcall::<_, _, Value>("some_method", ()).unwrap_or_else(|_| ruby.qnil().as_value()); // Better: handle error
}

Error Handling with RefCell

When using RefCell for interior mutability, handle borrow errors gracefully:

use std::cell::RefCell;
use magnus::{Error, Ruby};

#[magnus::wrap(class = "Counter")]
struct MutCounter(RefCell<u64>);

impl MutCounter {
fn new() -> Self {
MutCounter(RefCell::new(0))
}

fn increment(ruby: &Ruby, self_: &Self) -> Result<u64, Error> {
match self_.0.try_borrow_mut() {
Ok(mut value) => {
*value += 1;
Ok(*value)
},
Err(_) => Err(Error::new(
ruby.exception_runtime_error(),
"Cannot modify counter: already borrowed"
)),
}
}

// Better approach: complete borrows before starting new ones
fn safe_increment(&self) -> u64 {
let mut value = self.0.borrow_mut();
*value += 1;
*value
}
}

Conclusion

warning

Never use unwrap() or expect() in production code for your Ruby extensions. These can cause panics that will crash the Ruby VM. Always use proper error handling with Result and Error types.

Effective error handling makes your Ruby extensions more robust and user-friendly. By using the right exception types and providing clear error messages, you create a better experience for users of your extension.

Remember these key points:

  • Use Result&lt;T, Error&gt; for functions that can fail
  • Choose appropriate Ruby exception types
  • Provide clear, detailed error messages
  • Handle Rust panics to prevent VM crashes
  • Respect Ruby's exception hierarchy
tip

Test your extension with invalid inputs to ensure it throws appropriate Ruby exceptions instead of crashing.