Skip to content

Ruby Exception Handling

Exception handling is an essential part of writing robust programs. During program execution, various unexpected situations may occur, such as file not found, network connection failure, invalid input, etc. Ruby provides a complete exception handling mechanism to help developers gracefully handle these situations, improving program stability and user experience. This chapter will provide a detailed introduction to various exception handling methods and best practices in Ruby.

🎯 Exception Basics

What is an Exception

An exception is an abnormal situation that occurs during program execution, which interrupts the normal flow of the program. Exceptions in Ruby are objects that inherit from the Exception class or its subclasses.

ruby
# Basic exception handling
begin
  # Code that might error
  result = 10 / 0
rescue
  # Handle exception
  puts "An error occurred!"
end

# More specific exception handling
begin
  result = 10 / 0
rescue ZeroDivisionError
  puts "Cannot divide by zero!"
end

# Capture exception object
begin
  result = 10 / 0
rescue ZeroDivisionError => e
  puts "Error type: #{e.class}"
  puts "Error message: #{e.message}"
  puts "Error backtrace: #{e.backtrace}"
end

Ruby Exception Hierarchy

ruby
# Ruby exception class hierarchy (partial)
# Exception
#   ├── NoMemoryError
#   ├── ScriptError
#   │     ├── LoadError
#   │     ├── NotImplementedError
#   │     └── SyntaxError
#   ├── SignalException
#   │     └── Interrupt
#   ├── StandardError  # Most commonly used exception base class
#   │     ├── ArgumentError
#   │     ├── IOError
#   │     ├── IndexError
#   │     ├── LocalJumpError
#   │     ├── NameError
#   │     │     └── NoMethodError
#   │     ├── RangeError
#   │     ├── RegexpError
#   │     ├── RuntimeError
#   │     ├── SecurityError
#   │     ├── StandardError
#   │     ├── StopIteration
#   │     ├── SyntaxError
#   │     ├── SystemCallError
#   │     ├── SystemStackError
#   │     ├── ThreadError
#   │     ├── TypeError
#   │     └── ZeroDivisionError
#   ├── SystemExit
#   └── fatal

# Common exception type examples
begin
  # ArgumentError - argument error
  def greet(name)
    raise ArgumentError, "Name cannot be empty" if name.nil? || name.empty?
    puts "Hello, #{name}!"
  end
  
  greet("")  # Will throw ArgumentError
  
rescue ArgumentError => e
  puts "Argument error: #{e.message}"
end

begin
  # TypeError - type error
  result = "hello" + 5  # Will throw TypeError
  
rescue TypeError => e
  puts "Type error: #{e.message}"
end

begin
  # NoMethodError - method doesn't exist error
  obj = "hello"
  obj.nonexistent_method  # Will throw NoMethodError
  
rescue NoMethodError => e
  puts "Method error: #{e.message}"
end

🚨 Exception Handling Syntax

Basic Exception Handling Structure

ruby
# Complete exception handling structure
begin
  # Code that might error
  puts "Start execution"
  raise "This is a custom error"
  puts "This line will not execute"
  
rescue StandardError => e
  # Handle standard exceptions
  puts "Caught exception: #{e.message}"
  
rescue Exception => e
  # Handle all exceptions (not recommended)
  puts "Caught all exceptions: #{e.message}"
  
else
  # Execute when no exception occurs
  puts "No exception occurred"
  
ensure
  # Execute regardless of whether exception occurred
  puts "Cleanup work"
end

Multiple Exception Handling

ruby
# Handle multiple exception types
begin
  # Code that might throw different exceptions
  file = File.open("nonexistent.txt")
  data = file.read
  result = 10 / 0
  
rescue Errno::ENOENT => e
  # File not found error
  puts "File not found: #{e.message}"
  
rescue ZeroDivisionError => e
  # Division by zero error
  puts "Division by zero error: #{e.message}"
  
rescue IOError => e
  # IO error
  puts "IO error: #{e.message}"
  
rescue StandardError => e
  # Other standard errors
  puts "Other error: #{e.message}"
end

# Use array to handle multiple exceptions
begin
  # Code that might error
  raise ArgumentError, "Argument error"
  
rescue [ArgumentError, TypeError, NameError] => e
  puts "Caught type-related error: #{e.class} - #{e.message}"
end

Exception Re-raising and Nesting

ruby
# Re-raise exception
begin
  begin
    raise "Inner error"
  rescue => e
    puts "Internal catch: #{e.message}"
    # Re-raise exception
    raise
  end
  
rescue => e
  puts "External catch: #{e.message}"
end

# Throw different exception
begin
  begin
    raise "Original error"
  rescue => e
    puts "Caught original error: #{e.message}"
    # Throw new exception
    raise RuntimeError, "Transformed error"
  end
  
rescue RuntimeError => e
  puts "Caught transformed error: #{e.message}"
end

# Exception chaining (Ruby 2.1+)
begin
  begin
    raise "Original error"
  rescue => e
    raise "New error", cause: e
  end
  
rescue => e
  puts "New error: #{e.message}"
  puts "Original error: #{e.cause.message}" if e.cause
end

🏗️ Custom Exceptions

Creating Custom Exception Classes

ruby
# Basic custom exception
class CustomError < StandardError
end

# Custom exception with constructor
class ValidationError < StandardError
  attr_reader :field, :value
  
  def initialize(field, value, message = nil)
    @field = field
    @value = value
    message ||= "Field '#{field}' value '#{value}' is invalid"
    super(message)
  end
end

# Using custom exception
def validate_age(age)
  raise ValidationError.new("age", age) if age < 0 || age > 150
  true
end

begin
  validate_age(-5)
rescue ValidationError => e
  puts "Validation error: #{e.message}"
  puts "Field: #{e.field}, Value: #{e.value}"
end

# Hierarchical custom exceptions
class ApplicationError < StandardError
end

class DatabaseError < ApplicationError
  attr_reader :query
  
  def initialize(query, message = nil)
    @query = query
    message ||= "Database query failed: #{query}"
    super(message)
  end
end

class NetworkError < ApplicationError
  attr_reader :url
  
  def initialize(url, message = nil)
    @url = url
    message ||= "Network request failed: #{url}"
    super(message)
  end
end

# Using hierarchical exceptions
def fetch_data(url)
  # Simulate network request
  if url.include?("error")
    raise NetworkError.new(url, "Connection timeout")
  end
  "Data content"
end

begin
  data = fetch_data("http://example.com/error")
rescue ApplicationError => e
  puts "Application error: #{e.message}"
  case e
  when NetworkError
    puts "Network error, URL: #{e.url}"
  when DatabaseError
    puts "Database error, query: #{e.query}"
  end
end

Throwing Exceptions

ruby
# Use raise to throw exception
raise "This is an error message"
raise RuntimeError, "Runtime error"
raise RuntimeError.new("Custom runtime error")

# Use fail to throw exception (same as raise)
fail "This is also an error message"
fail ArgumentError, "Argument error"

# Throw specific exception object
exception = ArgumentError.new("Invalid argument")
raise exception

# Re-throw current exception
begin
  raise "Original error"
rescue
  puts "Processing..."
  raise  # Re-throw
end

Exception Checking and Handling

ruby
# Check if exception can be caught
def safe_operation
  catch(:done) do
    # Operations that might throw exceptions
    throw :done, "Operation completed"
  end
rescue => e
  "Error occurred: #{e.message}"
end

# Use retry for retry
attempt = 0
begin
  attempt += 1
  puts "Attempt ##{attempt}"
  raise "Simulated error" if attempt < 3
  puts "Success!"
  
rescue => e
  if attempt < 3
    puts "Retrying..."
    retry
  else
    puts "Final failure: #{e.message}"
  end
end

Exception Information Access

ruby
begin
  raise "Custom error message"
  
rescue => e
  puts "Exception class: #{e.class}"
  puts "Exception message: #{e.message}"
  puts "Exception backtrace:"
  e.backtrace.each { |line| puts "  #{line}" }
  
  # Get detailed exception information
  puts "Exception details: #{e.inspect}"
  
  # Get cause (Ruby 2.1+)
  puts "Exception cause: #{e.cause}" if e.cause
end

🎯 Practical Exception Handling Examples

File Operation Exception Handling

ruby
class FileProcessor
  # Safe file reading
  def self.safe_read(filename)
    File.open(filename, "r") do |file|
      file.read
    end
  rescue Errno::ENOENT
    puts "Error: File '#{filename}' does not exist"
    nil
  rescue Errno::EACCES
    puts "Error: No permission to read file '#{filename}'"
    nil
  rescue => e
    puts "Unknown error occurred while reading file: #{e.message}"
    nil
  end
  
  # Safe file writing
  def self.safe_write(filename, content)
    File.open(filename, "w") do |file|
      file.write(content)
    end
    true
  rescue Errno::EACCES
    puts "Error: No permission to write file '#{filename}'"
    false
  rescue => e
    puts "Unknown error occurred while writing file: #{e.message}"
    false
  end
  
  # Process file and return result
  def self.process_file(filename)
    content = safe_read(filename)
    return nil unless content
    
    # Process content
    processed_content = content.upcase
    
    # Write new file
    output_filename = "#{filename}.processed"
    safe_write(output_filename, processed_content)
    
    output_filename
  end
end

# Using file processor
# result = FileProcessor.process_file("example.txt")
# if result
#   puts "File processing completed: #{result}"
# else
#   puts "File processing failed"
# end

Network Request Exception Handling

ruby
require 'net/http'
require 'uri'

class NetworkClient
  class NetworkError < StandardError
    attr_reader :url, :status_code
    
    def initialize(url, status_code = nil, message = nil)
      @url = url
      @status_code = status_code
      message ||= status_code ? 
        "HTTP #{status_code} error: #{url}" : 
        "Network request failed: #{url}"
      super(message)
    end
  end
  
  class TimeoutError < NetworkError
    def initialize(url, message = nil)
      super(url, nil, message || "Request timeout: #{url}")
    end
  end
  
  # Send GET request
  def self.get(url, timeout: 5)
    uri = URI(url)
    
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = uri.scheme == 'https'
    http.open_timeout = timeout
    http.read_timeout = timeout
    
    response = http.get(uri.request_uri)
    
    case response
    when Net::HTTPSuccess
      response.body
    else
      raise NetworkError.new(url, response.code.to_i)
    end
    
  rescue Net::OpenTimeout, Net::ReadTimeout
    raise TimeoutError.new(url)
  rescue SocketError
    raise NetworkError.new(url, nil, "DNS resolution failed: #{url}")
  rescue => e
    raise NetworkError.new(url, nil, "Unknown network error: #{e.message}")
  end
  
  # Safe GET request
  def self.safe_get(url, timeout: 5)
    get(url, timeout: timeout)
  rescue NetworkError => e
    puts "Network error: #{e.message}"
    nil
  end
end

# Using network client
# response = NetworkClient.safe_get("https://api.github.com/users/octocat")
# if response
#   puts "Response content: #{response[0..100]}..."
# else
#   puts "Request failed"
# end

Data Validation Exception Handling

ruby
class DataValidator
  class ValidationError < StandardError
    attr_reader :field, :value, :constraint
    
    def initialize(field, value, constraint, message = nil)
      @field = field
      @value = value
      @constraint = constraint
      message ||= "Field '#{field}' value '#{value}' does not satisfy constraint '#{constraint}'"
      super(message)
    end
  end
  
  # Validate required fields
  def self.validate_required(data, field)
    value = data[field]
    raise ValidationError.new(field, value, "required", 
            "Field '#{field}' is required") if value.nil? || value.to_s.empty?
    value
  end
  
  # Validate numeric range
  def self.validate_range(data, field, min, max)
    value = data[field]
    raise ValidationError.new(field, value, "range(#{min}-#{max})", 
            "Field '#{field}' must be between #{min} and #{max}") unless value.is_a?(Numeric) && value.between?(min, max)
    value
  end
  
  # Validate email format
  def self.validate_email(data, field)
    value = data[field]
    email_pattern = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
    raise ValidationError.new(field, value, "email", 
            "Field '#{field}' must be a valid email address") unless value.to_s.match?(email_pattern)
    value
  end
  
  # Batch validation
  def self.validate(data, rules)
    errors = []
    
    rules.each do |field, rule|
      begin
        case rule[:type]
        when :required
          validate_required(data, field)
        when :range
          validate_range(data, field, rule[:min], rule[:max])
        when :email
          validate_email(data, field)
        end
      rescue ValidationError => e
        errors << e
      end
    end
    
    unless errors.empty?
      raise ValidationError.new("multiple", nil, "multiple", 
              "Validation failed, #{errors.length} errors in total")
    end
    
    true
  end
end

# Using data validator
# user_data = {
#   name: "Alice",
#   age: 25,
#   email: "alice@example.com"
# }
# 
# validation_rules = {
#   name: { type: :required },
#   age: { type: :range, min: 0, max: 150 },
#   email: { type: :email }
# }
# 
# begin
#   DataValidator.validate(user_data, validation_rules)
#   puts "Data validation passed"
# rescue DataValidator::ValidationError => e
#   puts "Validation error: #{e.message}"
# end

📊 Exception Handling Performance Optimization

Exception Handling Performance Considerations

ruby
# Avoid using exceptions to control normal flow
# Not recommended: use exception to control flow
def find_user_bad(id)
  begin
    User.find(id)
  rescue ActiveRecord::RecordNotFound
    nil
  end
end

# Recommended: use normal method
def find_user_good(id)
  User.find_by(id: id)
end

# Pre-check to avoid exceptions
def safe_divide(dividend, divisor)
  # Pre-check
  return nil if divisor == 0
  
  dividend / divisor
end

# Use rescue modifier for simple cases
def safe_parse_integer(string)
  Integer(string)
rescue ArgumentError
  nil
end

# Exception handling in batch operations
def process_items(items)
  results = []
  errors = []
  
  items.each_with_index do |item, index|
    begin
      result = process_item(item)
      results << { index: index, item: item, result: result }
    rescue => e
      errors << { index: index, item: item, error: e.message }
    end
  end
  
  { results: results, errors: errors }
end

def process_item(item)
  # Process single item
  item.upcase
end

Exception Logging

ruby
require 'logger'

class ExceptionLogger
  def initialize(log_file = "error.log")
    @logger = Logger.new(log_file)
    @logger.level = Logger::ERROR
  end
  
  # Log exception
  def log_exception(e, context = {})
    error_info = {
      exception_class: e.class.name,
      message: e.message,
      backtrace: e.backtrace&.first(10),
      context: context,
      timestamp: Time.now
    }
    
    @logger.error(error_info.to_json)
  end
  
  # Exception handling with context
  def handle_with_context(context = {})
    yield
  rescue => e
    log_exception(e, context)
    raise  # Re-throw exception
  end
end

# Using exception logger
# logger = ExceptionLogger.new("app_errors.log")
# 
# logger.handle_with_context(user_id: 123, action: "update_profile") do
#   # Operations that might error
#   update_user_profile(user_id: 123, data: profile_data)
# end

🛡️ Exception Handling Best Practices

1. Exception Handling Principles

ruby
# Principle 1: Specific exception handling
# Bad practice
begin
  # Multiple possible errors
  file = File.open("data.txt")
  data = JSON.parse(file.read)
  result = 10 / data["divisor"]
rescue => e
  puts "Error occurred: #{e.message}"
end

# Good practice
begin
  file = File.open("data.txt")
  data = JSON.parse(file.read)
  result = 10 / data["divisor"]
rescue Errno::ENOENT => e
  puts "File not found: #{e.message}"
rescue JSON::ParserError => e
  puts "JSON parsing error: #{e.message}"
rescue ZeroDivisionError => e
  puts "Division by zero error: #{e.message}"
rescue => e
  puts "Unknown error: #{e.message}"
end

# Principle 2: Don't ignore exceptions
# Bad practice
begin
  risky_operation
rescue
  # Do nothing
end

# Good practice
begin
  risky_operation
rescue => e
  log_error(e)
  # Or re-throw
  raise
end

# Principle 3: Clean up resources
# Good practice
file = nil
begin
  file = File.open("data.txt")
  process_file(file)
ensure
  file&.close  # Ensure file is closed
end

# Better practice
File.open("data.txt") do |file|
  process_file(file)
end  # File automatically closed

2. Custom Exception Design

ruby
# Well-designed exception hierarchy
module MyApp
  class Error < StandardError
    attr_reader :context
    
    def initialize(message = nil, context = {})
      @context = context
      super(message)
    end
  end
  
  class ValidationError < Error
    attr_reader :field, :value
    
    def initialize(field, value, message = nil, context = {})
      @field = field
      @value = value
      message ||= "Field '#{field}' validation failed"
      super(message, context)
    end
  end
  
  class BusinessLogicError < Error
    attr_reader :code
    
    def initialize(code, message = nil, context = {})
      @code = code
      message ||= "Business logic error: #{code}"
      super(message, context)
    end
  end
  
  class ExternalServiceError < Error
    attr_reader :service, :http_status
    
    def initialize(service, http_status = nil, message = nil, context = {})
      @service = service
      @http_status = http_status
      message ||= http_status ? 
        "#{service} service error (HTTP #{http_status})" : 
        "#{service} service error"
      super(message, context)
    end
  end
end

# Using custom exceptions
begin
  raise MyApp::ValidationError.new("email", "invalid-email", 
          "Invalid email format", { user_id: 123 })
rescue MyApp::ValidationError => e
  puts "Validation error: #{e.message}"
  puts "Field: #{e.field}, Value: #{e.value}"
  puts "User ID: #{e.context[:user_id]}"
end

3. Exception Handling Patterns

ruby
# Retry pattern
class RetryableOperation
  def self.execute(max_retries: 3, base_delay: 1)
    retries = 0
    
    begin
      yield
    rescue => e
      retries += 1
      
      if retries <= max_retries
        delay = base_delay * (2 ** (retries - 1))  # Exponential backoff
        puts "Retry ##{retries}, waiting #{delay} seconds..."
        sleep(delay)
        retry
      else
        puts "Failed after #{max_retries} retries: #{e.message}"
        raise
      end
    end
  end
end

# Using retry pattern
# RetryableOperation.execute(max_retries: 3) do
#   # Operation that might fail
#   unstable_network_call
# end

# Circuit breaker pattern
class CircuitBreaker
  def initialize(threshold: 5, timeout: 60)
    @threshold = threshold
    @timeout = timeout
    @failure_count = 0
    @last_failure_time = nil
    @open = false
  end
  
  def call
    return yield if closed?
    
    if timeout_expired?
      @open = false
      @failure_count = 0
      return yield
    end
    
    raise "Circuit breaker is open"
  end
  
  def record_failure
    @failure_count += 1
    @last_failure_time = Time.now
    @open = @failure_count >= @threshold
  end
  
  private
  
  def closed?
    !@open
  end
  
  def timeout_expired?
    @last_failure_time && (Time.now - @last_failure_time) > @timeout
  end
end

# Using circuit breaker
# breaker = CircuitBreaker.new(threshold: 3, timeout: 30)
# 
# begin
#   breaker.call do
#     external_service_call
#   end
# rescue => e
#   breaker.record_failure
#   raise
# end

4. Real Application Examples

ruby
# Web application controller exception handling
class ApplicationController
  def handle_request
    begin
      # Process request
      process_request
      
    rescue ValidationError => e
      render json: { error: "Validation error", details: e.message }, status: 400
      
    rescue RecordNotFound => e
      render json: { error: "Resource not found", details: e.message }, status: 404
      
    rescue BusinessLogicError => e
      render json: { error: "Business error", code: e.code, details: e.message }, status: 422
      
    rescue ExternalServiceError => e
      Rails.logger.error "External service error: #{e.message}"
      render json: { error: "Service temporarily unavailable" }, status: 503
      
    rescue => e
      Rails.logger.error "Unhandled exception: #{e.class} - #{e.message}"
      Rails.logger.error e.backtrace.join("\n")
      render json: { error: "Internal server error" }, status: 500
    end
  end
  
  private
  
  def process_request
    # Actual request processing logic
  end
end

# Database operation exception handling
class DatabaseManager
  def self.with_transaction
    ActiveRecord::Base.transaction do
      yield
    end
  rescue ActiveRecord::RecordInvalid => e
    Rails.logger.error "Record validation failed: #{e.message}"
    raise ValidationError.new("database", nil, "validation", e.message)
  rescue ActiveRecord::RecordNotFound => e
    Rails.logger.error "Record not found: #{e.message}"
    raise RecordNotFound.new(e.message)
  rescue ActiveRecord::StatementInvalid => e
    Rails.logger.error "SQL statement error: #{e.message}"
    raise BusinessLogicError.new("DB001", "Database operation failed")
  end
end

📚 Next Steps

After mastering Ruby exception handling, continue learning:

Continue your Ruby learning journey!

Content is for learning and research only.