03 · Exception Handling¶
Things go wrong at runtime: files don't exist, APIs time out, users type garbage into forms. Ruby's exception handling lets you contain those failures instead of letting them crash the whole program — and, just as importantly, lets you guarantee that cleanup code (closing a file, releasing a lock) runs no matter what happened.
begin/rescue — catching an exception¶
begin
1 / 0
rescue ZeroDivisionError => e
puts "Can't divide by zero: #{e.message}"
end
# Can't divide by zero: divided by 0
rescue catches the named exception class and any subclass of it. If
you omit the class entirely, Ruby rescues StandardError (and its
subclasses) by default — not literally everything:
begin
raise "something broke" # shorthand for raise RuntimeError, "something broke"
rescue => e # equivalent to `rescue StandardError => e`
puts "#{e.class}: #{e.message}"
end
# RuntimeError: something broke
Rescuing multiple, specific exception types¶
List the most specific exceptions first — Ruby checks rescue clauses top
to bottom and uses the first match:
def parse_number(text)
Integer(text)
rescue ArgumentError => e
puts "Not a valid integer: #{text.inspect}"
nil
rescue TypeError => e
puts "Wrong type entirely: #{e.message}"
nil
end
parse_number("42") # (no output, returns 42)
parse_number("abc") # Not a valid integer: "abc"
parse_number(nil) # Wrong type entirely: can't convert nil into Integer
Notice rescue can go directly in a def/end without a matching begin
— Ruby methods have an implicit begin block around their whole body.
ensure — always runs, exception or not¶
def read_config(path)
file = File.open(path)
file.read
rescue Errno::ENOENT
puts "Config file missing, using defaults"
"{}"
ensure
file&.close # runs whether the rescue fired or not, or even if `read` raised something else
puts "Cleanup done"
end
read_config("does_not_exist.json")
# Config file missing, using defaults
# Cleanup done
ensure runs even if the method returns from inside begin/rescue, or
even if a different, unrescued exception propagates out — it is Ruby's
guarantee for cleanup code, similar to finally in other languages.
else — code that runs only if nothing was raised¶
begin
result = 10 / 2
rescue ZeroDivisionError
puts "divide by zero!"
else
puts "Success: #{result}" # only runs when NO exception was raised
ensure
puts "always runs"
end
# Success: 5
# always runs
else is easy to forget about, but it's the cleanest way to separate
"code that might raise" from "code that should only run on success" —
putting the success-only logic inside begin instead would accidentally
get wrapped in the same rescue.
Custom exception classes¶
Real applications define their own exception hierarchy instead of raising
generic RuntimeError everywhere, so callers can rescue precisely the
failures they care about:
class ApiError < StandardError; end
class RateLimitedError < ApiError
def initialize(msg = "Rate limit exceeded, try again later")
super
end
end
class NotFoundError < ApiError; end
def fetch_resource(status)
case status
when 429 then raise RateLimitedError
when 404 then raise NotFoundError, "Resource not found"
else "ok"
end
end
begin
fetch_resource(429)
rescue RateLimitedError => e
puts "Backing off: #{e.message}"
rescue ApiError => e # catches NotFoundError and any other ApiError subclass
puts "API problem: #{e.message}"
end
# Backing off: Rate limit exceeded, try again later
Subclassing StandardError (never Exception directly — see below) and
building a small hierarchy like this means callers can rescue narrowly
(RateLimitedError) or broadly (ApiError) depending on what they need.
Why StandardError, not Exception¶
Exception is Ruby's root exception class, but it also covers things like
SyntaxError, NoMemoryError, and SystemExit — failures a program
generally should not try to catch and recover from. rescue with no
class, and virtually every exception you define yourself, should build on
StandardError:
# Don't do this:
class MyError < Exception; end # also gets caught by broad `rescue Exception`,
# which can accidentally swallow SystemExit,
# Interrupt (Ctrl-C), etc.
# Do this:
class MyError < StandardError; end
retry — attempting the risky operation again¶
retry jumps back to the top of the begin block, which is exactly what
you want for transient failures like a flaky network call. Always cap the
attempts — an unconditional retry can loop forever:
attempts = 0
begin
attempts += 1
raise "simulated timeout" if attempts < 3
puts "Succeeded on attempt #{attempts}"
rescue => e
if attempts < 3
puts "Attempt #{attempts} failed (#{e.message}), retrying..."
retry
else
puts "Giving up after #{attempts} attempts"
end
end
# Attempt 1 failed (simulated timeout), retrying...
# Attempt 2 failed (simulated timeout), retrying...
# Succeeded on attempt 3
Re-raising and raise with no arguments¶
Inside a rescue block, a bare raise re-raises the exception currently
being handled — useful for logging without swallowing the error:
def risky_operation
yield
rescue => e
puts "Logging error: #{e.message}"
raise # re-raises the SAME exception, preserving its original backtrace
end
begin
risky_operation { raise "boom" }
rescue RuntimeError => e
puts "Caller saw: #{e.message}"
end
# Logging error: boom
# Caller saw: boom
How It Actually Works¶
raise doesn't return control flow normally — it triggers stack
unwinding: MRI walks back up the call stack frame by frame, at each level
checking whether that frame is inside a begin/rescue (or method-level
rescue) whose rescue clause's class list matches (via ===, so
subclasses of StandardError match a bare rescue) the raised exception's
class. The first matching frame's rescue body runs; if none match, the
process terminates and prints the backtrace, which is really just the list
of frames the unwinder walked through, each holding its file/line/method
name. ensure blocks are guaranteed to run during this unwind regardless
of whether an exception was raised, returned from, or even thrown past —
MRI implements this by registering ensure handlers on the frame itself, so
unwinding through a frame always executes its ensure code before continuing
further up the stack. This is also why raising inside a rescue block
replaces $! and the original exception becomes accessible only via
cause, forming an exception-chain linked list.
Cheat sheet¶
| Keyword | Purpose |
|---|---|
begin ... rescue ... end |
catch exceptions raised inside begin |
rescue SomeError => e |
catch a specific class (and its subclasses) |
else |
runs only if no exception was raised |
ensure |
always runs — cleanup code |
retry |
jump back to the top of begin and try again |
raise (no args, inside rescue) |
re-raise the current exception |
raise SomeError, "message" |
raise a specific custom exception |
Exercise¶
Write a method safe_divide(a, b) that returns the division result, or
rescues ZeroDivisionError and returns nil while printing a friendly
message — use ensure to always print "Division attempted." regardless
of outcome. Then define a small exception hierarchy — ValidationError <
StandardError, with subclasses BlankFieldError and TooLongError — and
write a method validate_username(name) that raises the appropriate one
(blank, or longer than 20 characters) so a caller can rescue either
specifically or ValidationError broadly.