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 --> Ielse — 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 ofretrycalls with a counter — unboundedretrycan 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)
Inlinerescueis concise, but use it carefully. It catches allStandardError— including errors you might not have anticipated. If you only want to catch a specific type, stick with the explicitbegin/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 caught —
rescue => eonly catchesStandardErrorand below, notException. This is desired behavior.- Don’t rescue
Exception— it catchesInterrupt(Ctrl+C),SystemExit, andNoMemoryError, which shouldn’t be caught carelessly.elsefor code that only runs on success,ensurefor cleanup — the two complement each other in a singlebegin/rescueblock.- rescue at the method level without
begin/end— Ruby allows this and the result is cleaner than an explicitbegin/rescue/endinside a method.- Bound
retrywith a counter and exponential backoff — unboundedretryis 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.
rescuethe most specific types first — likeelsif, more specific rescues must come before more general ones.- Exception
causefor wrapping — when converting a third-party library exception into your domain exception,causepreserves the original exception for debugging.- Don’t use exceptions for normal control flow — exceptions are for exceptional conditions, not a replacement for
if/elseornilreturn values.