Exceptions and Failing Deliberately

Article · 12:00

An exception is for the case you cannot handle where you are, so the skill is choosing which failures to catch and letting the rest travel. Rescuing precisely, why rescuing Exception is always wrong, and when a failure is better as a value.

This lesson has 7 runnable examples. Press Try yourself! under any of them to open it in the editor.

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.

RB Example 1 11 lines
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

RB Example 2 14 lines
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

RB Example 3 19 lines
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

RB Example 4 25 lines
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.

RB Example 5 27 lines
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

RB Example 6 30 lines
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

RB Example 7 15 lines
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 => e catches StandardError; never rescue Exception.
  • Name the class you can handle; a bare rescue hides your own typos.
  • ensure always runs; retry needs a bound.
  • Subclass StandardError so callers can rescue at the granularity they need.
  • raise inside a rescue records cause; a bare raise re-raises unchanged.
  • An expected failure is a value, not an exception.