Error handling
This chapter introduces exception handling, the mechanism that allows you to react when something goes wrong in a program. Instead of silent crashes or unexplained nil values, you will learn how to raise, catch, and handle errors properly.
Principle
Imagine trying to load a save file that does not exist, or a user entering a negative level for a Pokemon. Without error handling, the program crashes with a cryptic message.
In Ruby, errors are objects. When something goes wrong, you raise an exception. The program walks back up the call stack until it finds a rescue block able to catch it. If no rescue catches it, the program stops.
This mechanism separates normal code from error-handling code: you write the happy path, and you handle problematic cases in dedicated blocks.
Raising an exception with raise
raise interrupts execution and creates an exception object:
# Simple form: creates a RuntimeError
raise 'Pokémon not found'
# With a specific class
raise ArgumentError, 'Level must be between 1 and 100'
raise 'message'creates aRuntimeErrorby default.raise ArgumentError, 'message'creates an error of a specific type. This is the recommended form because it allows catching errors by type.- Once raised, the exception interrupts everything: the following lines are not executed.
Catching with begin...rescue...end
The begin...rescue...end block catches exceptions:
begin
level = -5
raise ArgumentError, 'Level must be positive' if level < 1
puts "Level: #{level}"
rescue ArgumentError => error
puts "Error: #{error.message}"
end
puts 'Le programme continue normalement.'
Outputs:
Error: Level must be positive
Le programme continue normalement.
rescue ArgumentError => errorcatchesArgumentErrorexceptions and stores the object inerror.error.messagereturns the message passed toraise.- The code after
endruns normally: the program does not crash.
rescue inside a method
When the rescue covers the entire method, you can omit begin:
def create_pokemon(name, level)
raise ArgumentError, 'Name cannot be empty' if name.empty?
raise ArgumentError, 'Level must be between 1 and 100' unless (1..100).include?(level)
return { name: name, level: level }
rescue ArgumentError => error
puts "Creation error: #{error.message}"
return nil
end
result = create_pokemon('', 25)
puts result.inspect # => nil
result = create_pokemon('Pikachu', 25)
puts result.inspect # => {:name=>"Pikachu", :level=>25}
- The
rescueapplies to the entire body of the method. - We return
nilin therescueso the caller can check whether the creation succeeded.
Catching multiple exception types
You can chain multiple rescue clauses to handle different types of errors:
def load_pokemon(name)
raise ArgumentError, 'Empty name' if name.empty?
raise RuntimeError, "Pokémon #{name} not found" unless name == 'Pikachu'
return { name: name, level: 25 }
rescue ArgumentError => error
puts "Invalid argument: #{error.message}"
return nil
rescue RuntimeError => error
puts "Error: #{error.message}"
return nil
end
- Ruby tries each
rescuein order. The first one that matches is executed. - Always place the most specific exceptions first. Otherwise, a general clause would catch them all.
The exception hierarchy
Ruby exceptions form a class hierarchy (like the inheritance seen in chapter 9):
Exception
SignalException (Ctrl+C)
NoMemoryError
ScriptError
SyntaxError
LoadError
StandardError ← rescue catches this by default
ArgumentError
RuntimeError
TypeError
NameError
NoMethodError
IOError
ZeroDivisionError
KeyError
IndexError
rescuewithout a type catchesStandardErrorand all its descendants. This is the correct behavior in 99% of cases.- Never write
rescue Exception: this also catchesSignalException(Ctrl+C), making the program impossible to interrupt.
retry — try again
retry restarts execution at the beginning of the begin block. You must always use a counter to avoid infinite loops:
attempts = 0
begin
attempts += 1
puts "Attempt #{attempts}..."
# Simulate a random error
raise 'Loading failed' if rand(3) != 0
puts 'Loading succeeded!'
rescue RuntimeError => error
puts "Error: #{error.message}"
retry if attempts < 3
puts 'Giving up after 3 attempts.'
end
retrygoes back tobeginand re-executes the entire block.- Without the
attempts < 3counter, the loop would be infinite if the error is permanent. raisewithout an argument (alone) inside arescuere-raises the current exception, useful for giving up after all attempts.
ensure — execute no matter what
The ensure block always executes, whether an exception was raised or not:
def save(data, filename)
puts 'Saving in progress...'
raise 'Save error' if data.nil?
puts "Saved to #{filename}"
rescue RuntimeError => error
puts "Failure: #{error.message}"
ensure
puts 'Cleanup complete.'
end
save({ name: 'Pikachu' }, 'save.dat')
save(nil, 'save.dat')
Outputs:
Saving in progress...
Saved to save.dat
Cleanup complete.
Saving in progress...
Failure: Save error
Cleanup complete.
ensureruns in all cases: after normal code, after arescue, even after areturn.- It is the ideal place to close files, release resources, or display a final message.
Creating your own exceptions
For errors specific to your program, create classes that inherit from StandardError:
module Pokedex
class InvalidPokemonError < StandardError; end
class DuplicateError < StandardError; end
class TeamFullError < StandardError; end
end
Then use them like any other exception:
def add_pokemon(team, pokemon)
raise Pokedex::TeamFullError, "The team is full (6 max)" if team.size >= 6
raise Pokedex::DuplicateError, "#{pokemon} is already in the team" if team.include?(pokemon)
team << pokemon
return team
end
begin
team = ['Pikachu', 'Charizard', 'Blastoise', 'Venusaur', 'Dragonite', 'Gengar']
add_pokemon(team, 'Mewtwo')
rescue Pokedex::TeamFullError => error
puts error.message
end
Outputs: The team is full (6 max)
- Inheriting from
StandardErrorensures thatrescuewithout a type catches them. - The class name describes the problem:
TeamFullErroris more meaningful thanRuntimeError. - Grouping exceptions inside a module avoids name conflicts.
Re-raising an exception
raise without an argument inside a rescue re-raises the current exception. This is useful for logging a message while letting the error propagate:
def process_pokemon(data)
puts "Processing #{data[:name]}..."
raise 'Corrupted data' if data[:level].nil?
return data
rescue RuntimeError => error
puts "Failure: #{error.message}"
raise
end
raisealone re-raises the exact same exception. The caller will have to catch it in turn.
Conclusion
raiseraises an exception. Preferraise ClassName, 'message'for typed errors.begin...rescue...endcatches exceptions.rescueinside adefworks withoutbegin.- Place the most specific
rescueclauses first. - Never write
rescue Exception. CatchStandardErroror something more specific. retryrestarts atbegin. Always protect it with a counter.ensurealways runs, even after an error or areturn.- Create error classes that inherit from
StandardErrorfor domain-specific errors. raisewithout an argument inside arescuere-raises the current exception.