Exceptions and Failing Deliberately
An exception is for the case you cannot handle where you are. The skill is choosing which failures to catch, and letting the rest travel.
def parse_age(text)
Integer(text)
rescue ArgumentError, TypeError
nil
end
puts parse_age("42").inspect
puts parse_age("not a number").inspect
puts parse_age(nil).inspect
# A method body is an implicit begin, so rescue needs no extra nesting.
Open this example in the editor and change it. Nothing you type is saved.
The hierarchy, and why you never rescue Exception
puts ArgumentError.ancestors.take_while { |c| c != Object }.inspect
# StandardError is everything a program can reasonably expect and handle.
# Above it sit things you must not swallow: SignalException when the user
# presses Ctrl-C, NoMemoryError, SystemExit when the program is quitting.
puts NoMemoryError.ancestors.include?(StandardError)
puts SystemExit.ancestors.include?(StandardError)
# A bare rescue means StandardError, not Exception - which is the sane default.
begin
raise "something broke"
rescue => e
puts "#{e.class}: #{e.message}"
end
Open this example in the editor and change it. Nothing you type is saved.
rescue Exception catches the interrupt that was trying to stop your program.
Write rescue => e and you get StandardError, which is what you meant.
Rescue precisely
def fetch_setting(settings, key)
settings.fetch(key)
rescue KeyError => e
puts " (missing #{key}, using default)"
"default"
end
settings = { theme: "dark" }
puts fetch_setting(settings, :theme)
puts fetch_setting(settings, :locale)
# This is the version to avoid. It catches the typo in the method name too,
# and turns a bug into a silent wrong answer.
def careless(settings, key)
settings.fetc(key)
rescue
"default"
end
puts careless(settings, :theme) # "default" - and no sign anything is wrong
Open this example in the editor and change it. Nothing you type is saved.
ensure, else, and retry
def with_connection
puts " open"
yield "connection"
rescue IOError => e
puts " failed: #{e.message}"
nil
else
puts " no exception was raised"
ensure
puts " close" # runs whichever way the method leaves
end
puts with_connection { |c| "used #{c}" }.inspect
puts with_connection { raise IOError, "reset by peer" }.inspect
# retry re-runs the begin block. It needs a bound, or it is an infinite loop.
attempts = 0
begin
attempts += 1
raise IOError, "timeout" if attempts < 3
puts "succeeded on attempt #{attempts}"
rescue IOError
retry if attempts < 3
puts "gave up"
end
Open this example in the editor and change it. Nothing you type is saved.
Your own exception types
Custom classes let a caller rescue exactly what it knows how to handle, and carry the data needed to report the problem.
class ValidationError < StandardError
attr_reader :field, :value
def initialize(field, value)
@field = field
@value = value
super("#{field} is invalid: #{value.inspect}")
end
end
class BlankError < ValidationError; end
def validate(record)
raise BlankError.new(:name, record[:name]) if record[:name].to_s.strip.empty?
raise ValidationError.new(:age, record[:age]) unless record[:age].is_a?(Integer)
"ok"
end
[{ name: "Aisha", age: 30 }, { name: "", age: 30 }, { name: "Kenji", age: "x" }].each do |record|
begin
puts validate(record)
rescue BlankError => e
puts "blank field #{e.field}"
rescue ValidationError => e
puts "invalid: #{e.message}"
end
end
Open this example in the editor and change it. Nothing you type is saved.
Rescuing ValidationError catches BlankError too, because it is a subclass.
That is the whole reason to build a small hierarchy of your own.
Keeping the cause when you re-raise
class StorageError < StandardError; end
def load_record
raise KeyError, "row 42 not found"
rescue KeyError => e
# raise inside a rescue records the original as the cause automatically.
raise StorageError, "could not load record"
end
begin
load_record
rescue StorageError => e
puts "#{e.class}: #{e.message}"
puts " caused by #{e.cause.class}: #{e.cause.message}"
end
# A bare raise inside a rescue re-raises the same exception untouched, which is
# how you log something and still let it travel.
def log_and_reraise
yield
rescue => e
puts " logged #{e.class}"
raise
end
begin
log_and_reraise { raise ArgumentError, "still broken" }
rescue ArgumentError => e
puts "reached the caller: #{e.message}"
end
Open this example in the editor and change it. Nothing you type is saved.
Exceptions are not for control flow
numbers = ["1", "2", "oops", "4"]
# Slow and noisy: an exception per bad entry, for a case you expect.
total = numbers.sum do |text|
begin
Integer(text)
rescue ArgumentError
0
end
end
puts total
# Say what you mean instead. Integer(..., exception: false) returns nil.
puts numbers.sum { |text| Integer(text, exception: false) || 0 }
puts numbers.filter_map { |text| Integer(text, exception: false) }.inspect
Open this example in the editor and change it. Nothing you type is saved.
Expected outcomes are values. Reserve exceptions for the ones that mean the work cannot continue here.
What to take away
rescue => ecatches StandardError; neverrescue Exception.- Name the class you can handle; a bare
rescuehides your own typos. ensurealways runs;retryneeds a bound.- Subclass StandardError so callers can rescue at the granularity they need.
raiseinside a rescue recordscause; a bareraisere-raises unchanged.- An expected failure is a value, not an exception.