Post

Ruby Exception Handling

Ruby Exception Handling: A Complete Guide to Robust Error Management

Exception handling is a critical aspect of writing robust Ruby applications. It allows you to gracefully handle errors, provide meaningful feedback to users, and prevent your application from crashing unexpectedly. Ruby provides a comprehensive exception handling system that gives developers fine-grained control over error management.

Understanding Ruby Exceptions

In Ruby, exceptions are objects that represent errors or exceptional conditions. When an error occurs, Ruby “raises” an exception, which can be “caught” and handled appropriately. All exceptions in Ruby inherit from the Exception class.

Ruby Exception Hierarchy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Exception
 +-- NoMemoryError
 +-- ScriptError
 |    +-- LoadError
 |    +-- NotImplementedError
 |    +-- SyntaxError
 +-- SecurityError
 +-- SignalException
 |    +-- Interrupt
 +-- StandardError -- default for rescue
 |    +-- ArgumentError
 |    +-- IOError
 |    |    +-- EOFError
 |    +-- IndexError
 |    |    +-- KeyError
 |    |    +-- StopIteration
 |    +-- LocalJumpError
 |    +-- NameError
 |    |    +-- NoMethodError
 |    +-- RangeError
 |    |    +-- FloatDomainError
 |    +-- RegexpError
 |    +-- RuntimeError -- default for raise
 |    +-- SystemCallError
 |    |    +-- Errno::*
 |    +-- ThreadError
 |    +-- TypeError
 |    +-- ZeroDivisionError
 +-- SystemExit
 +-- SystemStackError
 +-- fatal -- impossible to rescue

Basic Exception Handling with begin/rescue

The most common way to handle exceptions is using the begin/rescue block:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
begin
  # Code that might raise an exception
  risky_operation
rescue
  # Code to handle the exception
  puts "An error occurred!"
end

# Example with specific exception type
begin
  result = 10 / 0
rescue ZeroDivisionError
  puts "Cannot divide by zero!"
  result = nil
end

Rescuing Multiple Exception Types

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
begin
  # Risky code here
  perform_operation
rescue ZeroDivisionError
  puts "Division by zero error"
rescue ArgumentError
  puts "Invalid argument provided"
rescue StandardError => e
  puts "Other error occurred: #{e.message}"
end

# Alternative syntax for multiple exceptions
begin
  perform_operation
rescue ZeroDivisionError, ArgumentError => e
  puts "Math or argument error: #{e.message}"
rescue => e
  puts "Unexpected error: #{e.message}"
end

Raising Exceptions

You can raise exceptions manually using the raise keyword:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Raise a generic RuntimeError
raise "Something went wrong!"

# Raise a specific exception type
raise ArgumentError, "Invalid input provided"

# Raise with custom exception class
raise ZeroDivisionError.new("Cannot divide by zero")

# Re-raise the current exception
begin
  1 / 0
rescue => e
  puts "Logging error: #{e.message}"
  raise  # Re-raises the same exception
end

Creating Custom Exceptions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Define custom exception classes
class ValidationError < StandardError
  attr_reader :field, :value
  
  def initialize(field, value, message = nil)
    @field = field
    @value = value
    super(message || "Validation failed for #{field}: #{value}")
  end
end

class DatabaseConnectionError < StandardError
  def initialize(host, port)
    super("Failed to connect to database at #{host}:#{port}")
  end
end

# Usage
def validate_email(email)
  raise ValidationError.new(:email, email, "Invalid email format") unless email.include?("@")
end

begin
  validate_email("invalid-email")
rescue ValidationError => e
  puts "Field: #{e.field}, Value: #{e.value}"
  puts "Error: #{e.message}"
end

Complete Exception Handling Syntax

Ruby provides several clauses for comprehensive exception handling:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
begin
  # Code that might raise an exception
  risky_operation
rescue SpecificError => e
  # Handle specific exceptions
  handle_specific_error(e)
rescue => e
  # Handle any StandardError
  handle_general_error(e)
else
  # Executed only if no exception was raised
  puts "Operation completed successfully"
ensure
  # Always executed, regardless of exceptions
  cleanup_resources
end

The ensure Clause

The ensure clause is always executed, making it perfect for cleanup operations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def read_file(filename)
  file = nil
  begin
    file = File.open(filename, 'r')
    content = file.read
    return content
  rescue IOError => e
    puts "Error reading file: #{e.message}"
    return nil
  ensure
    # This always runs, even if an exception occurs
    file&.close
    puts "File handle closed"
  end
end

Practical Example 1: File Processing with Exception Handling

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
class FileProcessor
  class FileProcessingError < StandardError; end
  class InvalidFileTypeError < FileProcessingError; end
  class FileSizeError < FileProcessingError; end
  
  ALLOWED_EXTENSIONS = %w[.txt .csv .json].freeze
  MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB
  
  def initialize(log_errors: true)
    @log_errors = log_errors
    @processed_files = []
    @failed_files = []
  end
  
  def process_files(file_paths)
    file_paths.each do |path|
      begin
        process_single_file(path)
        @processed_files << path
        puts "✓ Successfully processed: #{path}"
      rescue FileProcessingError => e
        @failed_files << { path: path, error: e.message, type: e.class.name }
        log_error("Processing failed for #{path}: #{e.message}") if @log_errors
      rescue => e
        @failed_files << { path: path, error: e.message, type: "UnexpectedError" }
        log_error("Unexpected error processing #{path}: #{e.message}") if @log_errors
      end
    end
    
    generate_report
  end
  
  private
  
  def process_single_file(file_path)
    # Check if file exists
    raise FileProcessingError, "File not found: #{file_path}" unless File.exist?(file_path)
    
    # Validate file extension
    extension = File.extname(file_path).downcase
    unless ALLOWED_EXTENSIONS.include?(extension)
      raise InvalidFileTypeError, "Unsupported file type: #{extension}. Allowed: #{ALLOWED_EXTENSIONS.join(', ')}"
    end
    
    # Check file size
    file_size = File.size(file_path)
    if file_size > MAX_FILE_SIZE
      raise FileSizeError, "File too large: #{file_size} bytes (max: #{MAX_FILE_SIZE})"
    end
    
    # Process the file
    File.open(file_path, 'r') do |file|
      case extension
      when '.txt'
        process_text_file(file)
      when '.csv'
        process_csv_file(file)
      when '.json'
        process_json_file(file)
      end
    end
  end
  
  def process_text_file(file)
    content = file.read
    # Simulate processing
    raise FileProcessingError, "Text file is empty" if content.strip.empty?
    
    # Count lines and words
    lines = content.lines.count
    words = content.split.count
    puts "  Text file stats: #{lines} lines, #{words} words"
  end
  
  def process_csv_file(file)
    lines = file.readlines
    raise FileProcessingError, "CSV file has no data rows" if lines.length < 2
    
    puts "  CSV file stats: #{lines.length - 1} data rows"
  end
  
  def process_json_file(file)
    require 'json'
    content = file.read
    
    begin
      data = JSON.parse(content)
      puts "  JSON file stats: #{data.keys.count if data.is_a?(Hash)} top-level keys"
    rescue JSON::ParserError => e
      raise FileProcessingError, "Invalid JSON format: #{e.message}"
    end
  end
  
  def log_error(message)
    File.open('file_processing_errors.log', 'a') do |log_file|
      log_file.puts "[#{Time.now}] #{message}"
    end
  rescue => e
    puts "Failed to write to log file: #{e.message}"
  end
  
  def generate_report
    puts "\n" + "="*50
    puts "FILE PROCESSING REPORT"
    puts "="*50
    puts "Processed successfully: #{@processed_files.count}"
    puts "Failed to process: #{@failed_files.count}"
    
    unless @failed_files.empty?
      puts "\nFailed files:"
      @failed_files.each do |failure|
        puts "  ✗ #{failure[:path]}"
        puts "    Error: #{failure[:error]} (#{failure[:type]})"
      end
    end
    
    {
      successful: @processed_files,
      failed: @failed_files,
      total: @processed_files.count + @failed_files.count
    }
  end
end

# Create sample files for demonstration
sample_files = []

# Valid files
File.write('sample.txt', "This is a sample text file.\nWith multiple lines.")
File.write('data.csv', "name,age\nJohn,25\nJane,30")
File.write('config.json', '{"app": "demo", "version": "1.0"}')

# Invalid files
File.write('large_file.txt', "x" * (11 * 1024 * 1024))  # Too large
File.write('invalid.json', '{"invalid": json}')  # Invalid JSON
File.write('empty.txt', '')  # Empty file

sample_files = [
  'sample.txt', 'data.csv', 'config.json',
  'large_file.txt', 'invalid.json', 'empty.txt',
  'nonexistent.txt', 'document.pdf'  # Non-existent and unsupported type
]

# Process files with exception handling
processor = FileProcessor.new(log_errors: true)
report = processor.process_files(sample_files)

# Cleanup sample files
['sample.txt', 'data.csv', 'config.json', 'large_file.txt', 'invalid.json', 'empty.txt'].each do |file|
  File.delete(file) if File.exist?(file)
end

Practical Example 2: Network Request Handler with Retry Logic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
require 'net/http'
require 'uri'
require 'json'

class NetworkRequestHandler
  class NetworkError < StandardError; end
  class TimeoutError < NetworkError; end
  class ServerError < NetworkError; end
  class ClientError < NetworkError; end
  
  def initialize(max_retries: 3, timeout: 10)
    @max_retries = max_retries
    @timeout = timeout
  end
  
  def fetch_with_retry(url, method: :get, payload: nil)
    attempt = 1
    
    begin
      puts "Attempt #{attempt}: Fetching #{url}"
      response = make_request(url, method, payload)
      
      case response.code.to_i
      when 200..299
        puts "✓ Success (#{response.code})"
        return parse_response(response)
      when 400..499
        raise ClientError, "Client error (#{response.code}): #{response.message}"
      when 500..599
        raise ServerError, "Server error (#{response.code}): #{response.message}"
      else
        raise NetworkError, "Unexpected response (#{response.code}): #{response.message}"
      end
      
    rescue Timeout::Error
      raise TimeoutError, "Request timed out after #{@timeout} seconds"
    rescue ServerError, TimeoutError => e
      # Retry on server errors and timeouts
      if attempt <= @max_retries
        wait_time = 2 ** (attempt - 1)  # Exponential backoff
        puts "  ✗ #{e.message}"
        puts "  Retrying in #{wait_time} seconds... (#{attempt}/#{@max_retries})"
        sleep(wait_time)
        attempt += 1
        retry
      else
        puts "  ✗ Max retries exceeded"
        raise e
      end
    rescue ClientError, NetworkError => e
      # Don't retry on client errors
      puts "  ✗ #{e.message}"
      raise e
    rescue => e
      # Handle unexpected errors
      puts "  ✗ Unexpected error: #{e.message}"
      raise NetworkError, "Unexpected error: #{e.message}"
    end
  end
  
  def fetch_multiple(urls)
    results = {}
    
    urls.each do |url|
      begin
        results[url] = fetch_with_retry(url)
      rescue NetworkError => e
        results[url] = { error: e.message, type: e.class.name }
      rescue => e
        results[url] = { error: e.message, type: "UnexpectedError" }
      end
    end
    
    generate_summary(results)
  end
  
  private
  
  def make_request(url, method, payload)
    uri = URI(url)
    
    Net::HTTP.start(uri.host, uri.port, 
                   use_ssl: uri.scheme == 'https',
                   open_timeout: @timeout,
                   read_timeout: @timeout) do |http|
      
      case method
      when :get
        http.get(uri.path.empty? ? '/' : uri.path)
      when :post
        request = Net::HTTP::Post.new(uri.path)
        request.body = payload.to_json if payload
        request['Content-Type'] = 'application/json'
        http.request(request)
      else
        raise ArgumentError, "Unsupported HTTP method: #{method}"
      end
    end
  end
  
  def parse_response(response)
    content_type = response['content-type'] || ''
    
    if content_type.include?('application/json')
      begin
        JSON.parse(response.body)
      rescue JSON::ParserError => e
        raise NetworkError, "Invalid JSON response: #{e.message}"
      end
    else
      {
        content_type: content_type,
        body_length: response.body.length,
        headers: response.to_hash
      }
    end
  end
  
  def generate_summary(results)
    successful = results.count { |_, result| !result.key?(:error) }
    failed = results.count { |_, result| result.key?(:error) }
    
    puts "\n" + "="*50
    puts "NETWORK REQUEST SUMMARY"
    puts "="*50
    puts "Total requests: #{results.count}"
    puts "Successful: #{successful}"
    puts "Failed: #{failed}"
    
    unless failed.zero?
      puts "\nFailed requests:"
      results.each do |url, result|
        if result.key?(:error)
          puts "  ✗ #{url}"
          puts "    #{result[:error]} (#{result[:type]})"
        end
      end
    end
    
    results
  end
end

# Example usage with various URLs (some will fail)
urls = [
  'https://jsonplaceholder.typicode.com/posts/1',  # Valid JSON API
  'https://www.google.com',                        # Valid HTML
  'https://httpstat.us/500',                       # Server error (for retry demo)
  'https://httpstat.us/404',                       # Client error (no retry)
  'https://nonexistent-domain-12345.com',          # DNS error
]

handler = NetworkRequestHandler.new(max_retries: 2, timeout: 5)

puts "Fetching multiple URLs with exception handling and retry logic:"
results = handler.fetch_multiple(urls)

Method-Level Exception Handling

Ruby allows you to add rescue clauses directly to methods:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def risky_method
  # method body
  perform_operation
rescue ArgumentError => e
  puts "Invalid argument: #{e.message}"
  return nil
rescue => e
  puts "Unexpected error: #{e.message}"
  raise  # Re-raise if you can't handle it
end

# This is equivalent to:
def risky_method
  begin
    perform_operation
  rescue ArgumentError => e
    puts "Invalid argument: #{e.message}"
    return nil
  rescue => e
    puts "Unexpected error: #{e.message}"
    raise
  end
end

Exception Information and Backtrace

Ruby exceptions carry useful information for debugging:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
begin
  raise "Something went wrong!"
rescue => e
  puts "Exception class: #{e.class}"
  puts "Exception message: #{e.message}"
  puts "Backtrace:"
  puts e.backtrace.first(5)  # Show first 5 lines of backtrace
  
  # Full backtrace
  puts "\nFull backtrace:"
  e.backtrace.each_with_index do |line, index|
    puts "  #{index}: #{line}"
  end
end

Catch and Throw (Non-Local Exits)

Ruby provides catch and throw for non-local exits, which are different from exceptions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def find_user(users, target_name)
  catch(:found) do
    users.each do |user|
      user[:friends].each do |friend|
        throw(:found, friend) if friend[:name] == target_name
      end
    end
    nil  # Not found
  end
end

# Usage
users = [
  { name: "Alice", friends: [{ name: "Bob" }, { name: "Charlie" }] },
  { name: "David", friends: [{ name: "Eve" }, { name: "Frank" }] }
]

result = find_user(users, "Charlie")
puts result ? "Found: #{result[:name]}" : "Not found"

Common Exception Types and When to Use Them

Built-in Exceptions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# ArgumentError - Invalid arguments
def divide(a, b)
  raise ArgumentError, "Arguments must be numbers" unless a.is_a?(Numeric) && b.is_a?(Numeric)
  raise ZeroDivisionError, "Cannot divide by zero" if b.zero?
  a / b
end

# TypeError - Wrong type
def process_array(arr)
  raise TypeError, "Expected Array, got #{arr.class}" unless arr.is_a?(Array)
  arr.map(&:to_s)
end

# RuntimeError - General runtime errors
def validate_state
  raise "Invalid application state" unless valid_state?
end

# IOError - Input/output errors
def read_config
  raise IOError, "Config file is corrupted" unless valid_config_format?
end

Best Practices for Exception Handling

1. Be Specific with Exception Types

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Bad - too generic
begin
  operation
rescue
  puts "Something went wrong"
end

# Good - specific handling
begin
  operation
rescue ArgumentError => e
  puts "Invalid input: #{e.message}"
rescue IOError => e
  puts "File operation failed: #{e.message}"
rescue => e
  puts "Unexpected error: #{e.message}"
  raise  # Re-raise if you can't handle it properly
end

2. Don’t Ignore Exceptions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Bad - silently ignoring errors
begin
  risky_operation
rescue
  # Silent failure
end

# Good - at least log the error
begin
  risky_operation
rescue => e
  logger.error "Operation failed: #{e.message}"
  # Handle appropriately or re-raise
end

3. Use Custom Exceptions for Domain Logic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class BankAccount
  class InsufficientFundsError < StandardError
    attr_reader :requested_amount, :available_balance
    
    def initialize(requested, available)
      @requested_amount = requested
      @available_balance = available
      super("Insufficient funds: requested #{requested}, available #{available}")
    end
  end
  
  def withdraw(amount)
    raise InsufficientFundsError.new(amount, @balance) if amount > @balance
    @balance -= amount
  end
end

4. Always Clean Up Resources

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def process_file(filename)
  file = File.open(filename)
  begin
    # Process file
    process_data(file.read)
  ensure
    file.close if file
  end
end

# Or better, use blocks that auto-close
def process_file(filename)
  File.open(filename) do |file|
    process_data(file.read)
  end  # File automatically closed
end

Key Takeaways

  • Use specific exception types rather than generic rescue clauses
  • Create custom exceptions for domain-specific errors
  • Always clean up resources using ensure or block syntax
  • Don’t ignore exceptions - at minimum, log them
  • Use retry logic for transient failures (network, database)
  • Provide meaningful error messages for debugging
  • Re-raise exceptions you can’t handle properly
  • Use catch/throw for control flow, not error handling

Exception handling is crucial for building robust Ruby applications. By understanding the exception hierarchy, using appropriate rescue strategies, and following best practices, you can create applications that gracefully handle errors and provide excellent user experiences even when things go wrong.

This post is licensed under CC BY 4.0 by the author.