Exceptions #

Exception handling is the difference between a program that crashes with an incomprehensible message and a program that fails gracefully — logging what went wrong, cleaning up used resources, and telling the user something meaningful. Ruby uses an elegant, expressive begin/rescue/else/ensure mechanism. But there are important nuances that are often overlooked: Ruby’s exception hierarchy, when to rescue and when to let an exception propagate, how to use retry correctly, and why rescue Exception is a dangerous anti-pattern. This article covers all of these from the fundamentals to the patterns used in production applications.

Ruby’s Exception Hierarchy #

Before writing rescue, it’s important to understand Ruby’s exception class hierarchy — because rescue works by inheritance relationships, not string matching.

Exception
├── NoMemoryError
├── ScriptError
│   ├── LoadError
│   ├── NotImplementedError
│   └── SyntaxError
├── SignalException
│   ├── Interrupt          ← Ctrl+C
│   └── Signal
├── SystemExit             ← exit()
└── StandardError          ← rescue without a type catches this
    ├── ArgumentError
    ├── EncodingError
    ├── FiberError
    ├── IOError
    │   └── EOFError
    ├── IndexError
    │   ├── KeyError
    │   └── StopIteration
    ├── Math::DomainError
    ├── NameError
    │   └── NoMethodError
    ├── RangeError
    │   └── FloatDomainError
    ├── RegexpError
    ├── RuntimeError       ← raise "message" produces this
    ├── SystemCallError    ← errors from the OS
    ├── ThreadError
    ├── TypeError
    ├── ZeroDivisionError
    └── (your custom exceptions)

The crucial point: rescue without an explicit type only catches StandardError and its descendants — not Exception. This is desired behavior because Interrupt, SystemExit, and NoMemoryError should not be caught carelessly.

# rescue => e is equivalent to rescue StandardError => e
begin
  # risky code
rescue => e
  puts e.message   # only catches StandardError and its descendants
end

# ANTI-PATTERN: rescue Exception catches EVERYTHING including Ctrl+C!
begin
  loop { sleep 1 }
rescue Exception => e   # ← this catches Interrupt (Ctrl+C) — the program can't be stopped!
  puts "Caught: #{e.class}"
end

The begin/rescue/else/ensure Structure #

The complete exception handling structure in Ruby has four parts, each with a different role:

begin
  # Code that might raise an exception
  result = risky_operation()

rescue ArgumentError => e
  # Handle ArgumentError specifically
  puts "Invalid argument: #{e.message}"

rescue TypeError, ValueError => e
  # Handle several types at once in a single rescue
  puts "Wrong type or value: #{e.message}"

rescue IOError => e
  # Handle I/O errors
  puts "File/network error: #{e.message}"

rescue => e
  # Fallback for other StandardErrors not caught above
  puts "Unexpected error: #{e.class}#{e.message}"

else
  # Runs ONLY if no exception occurred
  puts "Success! Result: #{result}"

ensure
  # Runs ALWAYS — whether an exception occurred or not
  # The right place to close files, connections, etc.
  clean_up_resources()
end
flowchart TD
    A[begin block] --> B{Exception occurred?}
    B -- No --> C[else block\nruns]
    B -- Yes --> D{Which rescue\nmatches?}
    D -- rescue 1 --> E[Handle error 1]
    D -- rescue 2 --> F[Handle error 2]
    D -- fallback rescue --> G[Handle general error]
    D -- None match --> H[Exception propagates\nto the caller]
    C --> I[ensure block\nalways runs]
    E --> I
    F --> I
    G --> I
    H --> I

else — Often Forgotten #

The else block is the one new developers most often don’t know about. It runs only when there’s no exception — separating “code that succeeded” from “code that might fail”:

def read_config(path)
  begin
    content = File.read(path)
  rescue Errno::ENOENT => e
    puts "File not found: #{path}"
    return {}
  rescue Errno::EACCES => e
    puts "No permission to read: #{path}"
    return {}
  else
    # Only reached if File.read succeeded
    JSON.parse(content)
  ensure
    puts "Finished trying to read #{path}"
  end
end

ensure — Resource Cleanup #

ensure is the right place to close files, database connections, or any resource that must be cleaned up regardless of what happened:

def process_file(path)
  file = nil

  begin
    file = File.open(path, "r")
    content = file.read
    process(content)
  rescue IOError => e
    log.error("Failed to read file: #{e.message}")
    raise   # re-raise after logging
  ensure
    file&.close   # always close the file, even if raise was called
  end
end

# More idiomatic way: use the File.open block
# which automatically closes the file at the end of the block
def process_file(path)
  File.open(path, "r") do |file|
    process(file.read)
  end
rescue IOError => e
  log.error("Failed to read file: #{e.message}")
  raise
end

raise — Raising Exceptions #

raise is used to throw an exception explicitly. There are several forms:

# Form 1: raise with a String — produces RuntimeError
raise "An error occurred!"

# Form 2: raise with an exception class
raise ArgumentError
raise ArgumentError, "Age must be positive"

# Form 3: raise with a pre-created exception object
error = ArgumentError.new("Age must be positive")
raise error

# Form 4: bare raise inside rescue — re-raise the exception being handled
begin
  operation()
rescue => e
  log.error(e.message)
  raise   # re-raise the same exception to the caller
end

raise in Validation #

The most common pattern using raise is input validation at the start of a method:

def create_user(name:, email:, age:)
  raise ArgumentError, "Name cannot be empty"        if name.to_s.strip.empty?
  raise ArgumentError, "Invalid email format"        unless email.match?(/\A\S+@\S+\.\S+\z/)
  raise ArgumentError, "Age must be between 0-150"   unless (0..150).include?(age)

  User.new(name: name, email: email, age: age)
end

begin
  create_user(name: "", email: "not-an-email", age: -5)
rescue ArgumentError => e
  puts "Validation failed: #{e.message}"
end
# => Validation failed: Name cannot be empty  (stops at the first error)

rescue Inside Methods — Without begin/end #

Ruby allows rescue directly inside a method without an explicit begin...end. The whole method body is implicitly a begin block:

# ANTI-PATTERN: unnecessary begin/end inside a method
def divide(a, b)
  begin
    a / b
  rescue ZeroDivisionError
    0
  end
end

# CORRECT: rescue directly in the method — cleaner
def divide(a, b)
  a / b
rescue ZeroDivisionError
  0
end

# ensure also works at the method level
def open_and_process(path)
  file = File.open(path)
  process(file.read)
rescue IOError => e
  log.error(e)
  nil
ensure
  file&.close
end

retry — Trying Again After Failure #

retry restarts the begin block from the beginning — very useful for operations that can fail temporarily, like network requests or database connections:

def fetch_data_from_api(url, max_attempts: 3)
  attempts = 0

  begin
    attempts += 1
    puts "Attempt #{attempts}..."
    response = HTTP.get(url, timeout: 5)
    response.body
  rescue Net::TimeoutError, Errno::ECONNREFUSED => e
    puts "Failed: #{e.message}"
    if attempts < max_attempts
      delay = 2 ** attempts   # exponential backoff: 2s, 4s, 8s
      puts "Waiting #{delay} seconds before retrying..."
      sleep delay
      retry
    else
      raise "Failed after #{max_attempts} attempts: #{e.message}"
    end
  end
end
Always bound the number of retry calls with a counter — unbounded retry can cause an infinite loop. The exponential backoff pattern (increasing delay on each attempt) is the industry standard for retrying network requests because it doesn’t burden an already-overwhelmed server.
# A reusable retry abstraction
def with_retry(max: 3, delay: 1, exceptions: [StandardError])
  attempts = 0
  begin
    attempts += 1
    yield
  rescue *exceptions => e
    raise if attempts >= max
    sleep delay * attempts
    retry
  end
end

# Usage
with_retry(max: 5, delay: 2, exceptions: [Net::TimeoutError]) do
  HTTP.get("https://api.example.com/data")
end

Custom Exceptions #

Creating your own exception classes is a highly recommended practice for serious applications. Custom exceptions make code more expressive and enable more precise handling.

Custom Exception Hierarchy #

# Base class for all application errors
class AppError < StandardError
  attr_reader :code

  def initialize(message, code: nil)
    super(message)
    @code = code
  end
end

# Domain-specific errors
class ValidationError < AppError
  attr_reader :field, :value

  def initialize(field, value, message)
    super("Validation failed for '#{field}': #{message}", code: "VALIDATION_ERROR")
    @field = field
    @value = value
  end
end

class AuthenticationError < AppError
  def initialize(message = "Authentication failed")
    super(message, code: "AUTH_ERROR")
  end
end

class AuthorizationError < AppError
  def initialize(action, resource)
    super("Not allowed to perform '#{action}' on #{resource}", code: "AUTHZ_ERROR")
  end
end

class ResourceNotFoundError < AppError
  attr_reader :resource, :id

  def initialize(resource, id)
    super("#{resource} with ID #{id} not found", code: "NOT_FOUND")
    @resource = resource
    @id       = id
  end
end

class ExternalServiceError < AppError
  attr_reader :service, :original_error

  def initialize(service, original_error)
    super("Failed to reach #{service}: #{original_error.message}", code: "EXTERNAL_ERROR")
    @service        = service
    @original_error = original_error
  end
end

Using Custom Exceptions #

class UserService
  def find!(id)
    user = User.find(id)
    raise ResourceNotFoundError.new("User", id) unless user
    user
  end

  def update!(id, data)
    user = find!(id)

    data.each do |field, value|
      validate_field!(field, value)
    end

    user.update(data)
  rescue ResourceNotFoundError
    raise   # let it propagate to the caller
  rescue ValidationError => e
    # log the validation but let the caller handle it
    log.warn("Validation failed: #{e.field}=#{e.value}")
    raise
  end

  private

  def validate_field!(field, value)
    case field
    when :email
      unless value.match?(/\A\S+@\S+\.\S+\z/)
        raise ValidationError.new(field, value, "invalid email format")
      end
    when :age
      unless (0..150).include?(value.to_i)
        raise ValidationError.new(field, value, "must be between 0 and 150")
      end
    end
  end
end

# In a controller or handler
service = UserService.new

begin
  service.update!(999, email: "not-an-email")
rescue ResourceNotFoundError => e
  puts "[#{e.code}] #{e.message}"   # => [NOT_FOUND] User with ID 999 not found
rescue ValidationError => e
  puts "[#{e.code}] Field '#{e.field}': #{e.message}"
rescue AppError => e
  puts "[#{e.code}] App error: #{e.message}"
rescue => e
  puts "Unexpected system error: #{e.class}#{e.message}"
end

Backtraces — Tracing Where an Exception Originated #

A backtrace is the method call trail showing exactly where an exception occurred and how the program got there:

def level_three
  raise RuntimeError, "Error at level three!"
end

def level_two
  level_three
end

def level_one
  level_two
end

begin
  level_one
rescue => e
  puts "Exception: #{e.message}"
  puts "\nBacktrace (first 5 lines):"
  puts e.backtrace.first(5).map { |b| "  #{b}" }
end
# => Exception: Error at level three!
# => Backtrace:
# =>   program.rb:2:in 'level_three'
# =>   program.rb:6:in 'level_two'
# =>   program.rb:10:in 'level_one'
# =>   program.rb:14:in '<main>'

For production applications, don’t show backtraces to users — log them to a file or monitoring service:

def handle_production_exception(e)
  # Full log for developers
  File.open("log/error.log", "a") do |f|
    f.puts "[#{Time.now}] #{e.class}: #{e.message}"
    f.puts e.backtrace.join("\n")
    f.puts "---"
  end

  # A safe message to show the user
  "An error occurred. Our team has been notified."
end

Cause — Exception Chaining #

Since Ruby 2.1, exceptions can have a cause — the original exception that triggered the new one. This is very useful for wrapping exceptions from third-party libraries:

def call_payment_gateway(amount)
  begin
    # Simulate an error from a third-party gem
    raise Net::TimeoutError, "Connection timed out"
  rescue Net::TimeoutError => e
    # Wrap it into our domain exception, but preserve the cause
    raise ExternalServiceError.new("PaymentGateway", e)
  end
end

begin
  call_payment_gateway(150_000)
rescue ExternalServiceError => e
  puts "Error: #{e.message}"
  puts "Cause: #{e.cause&.class}#{e.cause&.message}"
end
# => Error: Failed to reach PaymentGateway: Connection timed out
# => Cause: Net::TimeoutError — Connection timed out

Exception Anti-Patterns to Avoid #

# ANTI-PATTERN 1: rescue Exception — catches Ctrl+C and SystemExit!
begin
  run_server
rescue Exception => e   # ← DON'T — this catches Interrupt and SystemExit
  puts "Error: #{e.message}"
end

# CORRECT: rescue StandardError or a more specific type
begin
  run_server
rescue StandardError => e
  puts "Error: #{e.message}"
end

# ANTI-PATTERN 2: swallowing exceptions without logging — the error just vanishes
begin
  important_operation()
rescue => e
  # nothing done — the bug becomes invisible!
end

# CORRECT: at minimum log before continuing
begin
  important_operation()
rescue => e
  log.error("important_operation failed: #{e.class}#{e.message}")
  # then decide: re-raise, or fall back to a default value
end

# ANTI-PATTERN 3: rescue too broad for a long block of code
begin
  step1
  step2
  step3   # ← if this fails, the rescue below doesn't know which step failed
  step4
rescue => e
  puts "Something failed: #{e.message}"
end

# CORRECT: rescue only the code that actually needs protection
step1   # no rescue needed — let it fail clearly
step2

begin
  step3   # only this one is risky
rescue => e
  handle_step3_failure(e)
end

step4

# ANTI-PATTERN 4: raising a string — produces an uninformative RuntimeError
raise "user not found"   # ← uninformative exception type

# CORRECT: raise with the right exception class
raise ResourceNotFoundError.new("User", id)
raise ArgumentError, "ID must be a positive Integer"

rescue in Other Contexts #

Besides begin/end and inside methods, rescue can also be used inline as a modifier — similar to postfix if and unless:

# rescue as an inline expression
value = Integer(input) rescue nil
# If Integer(input) fails (ArgumentError), value becomes nil

number = Float(text) rescue 0.0
hash   = JSON.parse(json_string) rescue {}

# Useful for conversions that might fail
def parse_date(text)
  Date.parse(text) rescue nil
end

date = parse_date("2024-01-15")     # => Date object
date = parse_date("not a date")     # => nil (not an exception)
Inline rescue is concise, but use it carefully. It catches all StandardError — including errors you might not have anticipated. If you only want to catch a specific type, stick with the explicit begin/rescue/end.

Idiomatic Exception Patterns #

The Fail-Fast Pattern #

For validation, it’s better to fail as early as possible than to continue with an invalid state:

# ANTI-PATTERN: slow validation — keeps going until it finally crashes
def process_order(order)
  if order
    if order[:items] && !order[:items].empty?
      if order[:total] && order[:total] > 0
        # only now starts processing — too deep
        create_invoice(order)
      end
    end
  end
end

# CORRECT: fail fast with raise — main logic un-nested
def process_order(order)
  raise ArgumentError, "Order cannot be nil"        unless order
  raise ArgumentError, "Order must have items"      if order[:items].to_a.empty?
  raise ArgumentError, "Total must be greater than zero" unless order[:total].to_i > 0

  # main logic — no nesting
  create_invoice(order)
end

The Exception-as-Control-Flow Pattern — Avoid It! #

Exceptions in Ruby are fast enough, but there’s still overhead. More importantly, using exceptions for normal control flow makes code hard to read:

# ANTI-PATTERN: exceptions for regular control flow
def find_user(email)
  begin
    User.find_by_email!(email)   # raises if not found
  rescue RecordNotFound
    nil
  end
end

# CORRECT: use a method that returns nil when not found
def find_user(email)
  User.find_by_email(email)   # returns nil if not found
end

# Exceptions for EXCEPTIONAL conditions — not normal flow
def find_user!(email)
  User.find_by_email(email) or raise ResourceNotFoundError.new("User", email)
end

Summary #

  • The exception hierarchy determines what gets caughtrescue => e only catches StandardError and below, not Exception. This is desired behavior.
  • Don’t rescue Exception — it catches Interrupt (Ctrl+C), SystemExit, and NoMemoryError, which shouldn’t be caught carelessly.
  • else for code that only runs on success, ensure for cleanup — the two complement each other in a single begin/rescue block.
  • rescue at the method level without begin/end — Ruby allows this and the result is cleaner than an explicit begin/rescue/end inside a method.
  • Bound retry with a counter and exponential backoff — unbounded retry is an infinite loop waiting to happen.
  • Build a custom exception hierarchy — descend from StandardError (or your own app base class), add relevant attributes, and give meaningful error codes.
  • Always log before swallowing an exception — silently ignored exceptions are hidden bugs. At minimum log the message and exception class.
  • rescue the most specific types first — like elsif, more specific rescues must come before more general ones.
  • Exception cause for wrapping — when converting a third-party library exception into your domain exception, cause preserves the original exception for debugging.
  • Don’t use exceptions for normal control flow — exceptions are for exceptional conditions, not a replacement for if/else or nil return values.

← Previous: Interfaces   Next: List (Array) →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact