Table of Contents
- Understanding Exceptions in Ruby
- Basic Exception Handling:
begin-rescue-end - Handling Specific Exceptions
- The
elseandensureClauses - Raising Exceptions with
raise - Creating Custom Exceptions
- Best Practices for Effective Exception Handling
- Advanced Techniques
- Testing Exceptions
- Conclusion
- References
1. Understanding Exceptions in Ruby
An exception is an object that represents an error or unexpected event that disrupts the normal flow of a program. In Ruby, exceptions are instances of classes that inherit from the root Exception class. When an error occurs (e.g., dividing by zero, accessing a nil object), Ruby “raises” an exception, which halts execution unless explicitly handled.
Exception Hierarchy in Ruby
Ruby’s exception system is hierarchical, with Exception as the topmost class. Most practical exceptions inherit from StandardError, a subclass of Exception. This distinction is critical because rescue (by default) only catches exceptions that inherit from StandardError—protecting you from accidentally suppressing critical system-level errors like SystemExit (triggered by exit) or NoMemoryError.
Here’s a simplified hierarchy of common exceptions:
Exception
├── StandardError (most errors you’ll handle inherit from this)
│ ├── ArgumentError (invalid arguments passed to a method)
│ ├── TypeError (wrong type of object passed)
│ ├── ZeroDivisionError (division by zero)
│ ├── RuntimeError (default for `raise "message"`)
│ └── ... (many others like `IOError`, `KeyError`)
├── SystemExit (raised by `exit`)
├── Interrupt (raised by Ctrl+C)
└── ... (other low-level exceptions)
Key Takeaway: Focus on rescuing StandardError subclasses unless you have a specific reason to handle lower-level exceptions like Interrupt.
2. Basic Exception Handling: begin-rescue-end
Ruby uses the begin-rescue-end block to handle exceptions. Code that might raise an error is wrapped in begin, and handlers for specific exceptions are defined with rescue.
Syntax:
begin
# Code that might raise an exception
rescue [ExceptionClass1, ExceptionClass2] => e
# Code to handle the exception (e is the exception object)
end
Example: Handling Division by Zero
Suppose you’re writing a calculator app. Dividing by zero is a common error—let’s handle it:
def safe_divide(a, b)
begin
a / b
rescue ZeroDivisionError => e
puts "Error: #{e.message}" # Output: "Error: divided by 0"
nil # Return nil instead of crashing
end
end
puts safe_divide(10, 2) # => 5
puts safe_divide(10, 0) # => Error: divided by 0; nil
Here, ZeroDivisionError is explicitly rescued, and the error message (e.message) is displayed. The method returns nil gracefully instead of crashing.
Rescue Without Explicit Exception Class
If you omit the exception class (e.g., rescue => e), Ruby defaults to rescuing StandardError (and its subclasses). Use this cautiously—over-broad rescues can hide bugs!
begin
10 / 0
rescue => e # Equivalent to `rescue StandardError => e`
puts "Caught: #{e.class} - #{e.message}" # => "Caught: ZeroDivisionError - divided by 0"
end
3. Handling Specific Exceptions
Catching specific exceptions is critical for writing maintainable code. A broad rescue (e.g., without an exception class) can accidentally suppress unrelated errors, making debugging harder.
Example: Differentiating Between Errors
Suppose you’re parsing user input. Users might enter a non-numeric value (triggering TypeError) or a zero (triggering ZeroDivisionError). Let’s handle both explicitly:
def calculate(a, b)
begin
a = a.to_i # May raise TypeError if a is not convertible (e.g., nil)
b = b.to_i
result = a / b # May raise ZeroDivisionError
puts "Result: #{result}"
rescue TypeError => e
puts "Invalid input: #{e.message}" # Handles non-numeric input
rescue ZeroDivisionError => e
puts "Math error: #{e.message}" # Handles division by zero
end
end
calculate("10", "2") # => Result: 5
calculate("abc", "2") # => Invalid input: nil can't be converted to Integer (if a is nil)
calculate("10", "0") # => Math error: divided by 0
Why This Matters: If we’d used a generic rescue, we couldn’t distinguish between invalid input and division by zero—making it harder to debug or provide meaningful feedback to users.
4. The else and ensure Clauses
Ruby extends begin-rescue-end with two optional clauses: else and ensure, which add flexibility to error handling.
else: Code to Run When No Exception Occurs
The else block runs only if no exception was raised in the begin block. It’s useful for code that depends on the begin block succeeding (e.g., logging success).
Example:
begin
file = File.open("data.txt", "r")
content = file.read
rescue IOError => e
puts "Failed to read file: #{e.message}"
else
puts "File read successfully! Content length: #{content.size}" # Runs only if no error
ensure
file.close if file # Cleanup (see below)
end
ensure: Code to Run Always
The ensure block runs regardless of whether an exception occurred—even if the begin block returns early or raises an unhandled error. It’s ideal for cleanup tasks like closing files, releasing network connections, or rolling back transactions.
Example: Ensuring a File is Closed
Files left open can cause resource leaks. Use ensure to guarantee closure:
file = nil
begin
file = File.open("data.txt", "r")
# ... read or modify the file ...
rescue IOError => e
puts "Error: #{e.message}"
ensure
file&.close # `&.` (safe navigation) avoids NoMethodError if file is nil
puts "File closed (if it was open)"
end
Output: Even if opening the file fails (e.g., file not found), ensure will still print “File closed (if it was open)”.
5. Raising Exceptions with raise
So far, we’ve handled exceptions raised by Ruby (e.g., ZeroDivisionError). You can also explicitly raise exceptions with the raise keyword to signal errors in your code (e.g., invalid input, failed validations).
Syntax for raise:
raise: Re-raises the current exception (used inrescueblocks).raise "message": Raises aRuntimeErrorwith the message.raise ExceptionClass: Raises an instance ofExceptionClass.raise ExceptionClass, "message": RaisesExceptionClasswith a custom message.
Example 1: Raising a Custom Message
def greet(name)
raise "Name cannot be empty" if name.empty?
"Hello, #{name}!"
end
greet("Alice") # => "Hello, Alice!"
greet("") # => RuntimeError: Name cannot be empty
Example 2: Raising a Specific Exception Class
For clarity, raise a specific exception class instead of the generic RuntimeError:
def validate_age(age)
raise ArgumentError, "Age must be a positive integer" unless age.is_a?(Integer) && age > 0
end
validate_age(25) # No error
validate_age(-5) # => ArgumentError: Age must be a positive integer
validate_age("25") # => ArgumentError: Age must be a positive integer
Re-Raising Exceptions
Sometimes you want to log an error and let it propagate up to a higher-level handler. Use raise without arguments in a rescue block to re-raise the current exception:
def risky_operation
begin
# Code that might fail
1 / 0
rescue ZeroDivisionError => e
log_error(e) # Log the error (e.g., to a file or monitoring tool)
raise # Re-raise the exception to let the caller handle it
end
end
def log_error(e)
puts "Logged error: #{e.class} - #{e.message}"
end
# Caller handles the re-raised exception
begin
risky_operation
rescue ZeroDivisionError => e
puts "Caller handled: #{e.message}"
end
# Output:
# Logged error: ZeroDivisionError - divided by 0
# Caller handled: divided by 0
6. Creating Custom Exceptions
Ruby allows you to define custom exception classes by inheriting from StandardError (or a subclass). Custom exceptions make your code more expressive and help distinguish between different error types.
How to Define a Custom Exception
class ValidationError < StandardError; end # Inherit from StandardError
You can also add custom behavior (e.g., error codes) to your exceptions:
class PaymentError < StandardError
attr_reader :code
def initialize(message, code)
super(message) # Call parent constructor
@code = code
end
end
Example: Using Custom Exceptions
Let’s build a simple payment processor with custom exceptions:
class PaymentError < StandardError; end
class InsufficientFundsError < PaymentError; end # Subclass of PaymentError
class InvalidCardError < PaymentError; end # Another subclass
def process_payment(amount, card)
raise InvalidCardError, "Expired card" if card.expired?
raise InsufficientFundsError, "Not enough balance" if card.balance < amount
# ... process payment ...
"Payment successful"
end
# Usage:
begin
process_payment(100, expired_card)
rescue InsufficientFundsError => e
puts "Payment failed: #{e.message}"
rescue InvalidCardError => e
puts "Invalid card: #{e.message}" # => "Invalid card: Expired card"
rescue PaymentError => e # Catches all PaymentError subclasses
puts "Payment error: #{e.message}"
end
Benefits:
- Clearer error categorization (e.g.,
InsufficientFundsErrorvs.InvalidCardError). - Callers can rescue specific error types (e.g., retry on
InsufficientFundsErrorbut abort onInvalidCardError).
7. Best Practices for Effective Exception Handling
Writing robust exception handling requires discipline. Here are key best practices to follow:
1. Rescue Specific Exceptions
Avoid generic rescue => e unless you truly want to handle all StandardError cases. Over-broad rescues hide bugs (e.g., rescuing NoMethodError accidentally).
Bad:
begin
user = User.find(params[:id])
user.update!(params)
rescue => e # Catches *all* StandardError (e.g., ActiveRecord::RecordNotFound, NoMethodError)
puts "Something went wrong" # Hides the root cause!
end
Good:
begin
user = User.find(params[:id])
user.update!(params)
rescue ActiveRecord::RecordNotFound
puts "User not found"
rescue ActiveRecord::RecordInvalid => e
puts "Validation failed: #{e.message}"
end
2. Avoid Empty rescue Blocks
An empty rescue silently ignores errors, making debugging nearly impossible. Always log or handle the error explicitly.
Bad:
begin
risky_operation # What if it fails? We’ll never know!
rescue
end
Good:
begin
risky_operation
rescue => e
Rails.logger.error("Risky operation failed: #{e.message}") # Log the error
raise # Re-raise if you can’t handle it
end
3. Use ensure for Cleanup
Always release resources (files, network connections, database transactions) in ensure to prevent leaks.
Example:
conn = nil
begin
conn = Database.connect # Acquire resource
conn.execute("UPDATE users ...")
rescue DatabaseError => e
conn.rollback if conn # Handle error
ensure
conn.close if conn # Release resource *always*
end
4. Don’t Use Exceptions for Control Flow
Exceptions are for exceptional cases, not regular program flow. For example, avoid using rescue to check if a hash key exists—use Hash#dig or key? instead.
Bad:
begin
value = data[:key]
rescue KeyError # Overkill!
value = "default"
end
Good:
value = data[:key] || "default" # Simpler and faster
5. Document Exceptions Your Methods Raise
Tell callers which exceptions your method might raise (e.g., in YARD docs or comments). This helps them handle errors appropriately.
# Calculates the square root of a number.
# @param n [Numeric] The number to square root.
# @raise [ArgumentError] If n is negative.
def sqrt(n)
raise ArgumentError, "n must be non-negative" if n < 0
Math.sqrt(n)
end
8. Advanced Techniques
Beyond the basics, Ruby offers advanced exception handling features for complex scenarios.
Exception Hierarchies
Design hierarchies of custom exceptions to group related errors. For example, a APIError base class with subclasses like NetworkError, TimeoutError, and InvalidResponseError. Callers can rescue APIError to handle all API-related issues or specific subclasses for granular control.
rescue in Blocks and Method Definitions
Ruby allows inline rescue in blocks or method definitions using the rescue modifier (syntax sugar for short handlers).
Example: Rescue in a Block
# Process an array, rescuing errors in individual elements
[1, 2, "three", 4].each do |num|
puts 10 / num rescue puts "Invalid number: #{num}"
end
# Output:
# 10
# 5
# Invalid number: three
# 2
Example: Rescue in a Method Definition
def safe_parse(json)
JSON.parse(json) rescue nil # Returns nil if parsing fails
end
Note: Use this sparingly—inline rescue can make code harder to read for complex cases.
Retrying Failed Operations with retry
The retry keyword restarts the begin block from the beginning. It’s useful for transient errors (e.g., network blips, temporary file locks).
Example: Retry on Network Failure
MAX_RETRIES = 3
retries = 0
begin
fetch_data_from_api # Might fail due to network issues
rescue NetworkError => e
retries += 1
if retries <= MAX_RETRIES
puts "Retrying (#{retries}/#{MAX_RETRIES})..."
retry # Restart the begin block
else
raise "Failed after #{MAX_RETRIES} retries: #{e.message}"
end
end
Caution: Always limit retries to avoid infinite loops!
9. Testing Exceptions
To ensure your exception handling works as expected, test that exceptions are raised (and handled) correctly. Most Ruby testing frameworks (Minitest, RSpec) provide tools for this.
Testing with Minitest
Use assert_raises to verify that a block raises a specific exception:
require "minitest/autorun"
class CalculatorTest < Minitest::Test
def test_divide_by_zero
assert_raises(ZeroDivisionError) { 10 / 0 }
end
def test_invalid_age_validation
assert_raises(ArgumentError) { validate_age(-5) }
assert_raises_with_message(ArgumentError, "Age must be positive") { validate_age(-5) }
end
end
Testing with RSpec
Use expect { ... }.to raise_error for RSpec:
RSpec.describe Calculator do
it "raises ZeroDivisionError when dividing by zero" do
expect { 10 / 0 }.to raise_error(ZeroDivisionError)
expect { 10 / 0 }.to raise_error(ZeroDivisionError, "divided by 0")
end
end
10. Conclusion
Exception handling is a cornerstone of writing robust Ruby applications. By mastering begin-rescue-end, raise, custom exceptions, and best practices like rescuing specificity and using ensure for cleanup, you can build programs that gracefully handle errors, communicate issues clearly, and recover from failures.
Remember: The goal isn’t to eliminate all errors, but to handle them in a way that makes your code resilient, maintainable, and user-friendly. Apply these techniques, and you’ll be well on your way to writing Ruby code that stands up to real-world chaos.