retry and Exception Hierarchies
Ruby’s begin/rescue block doesn’t just let you catch errors — it lets you jump back and try the whole thing again with the retry keyword. Whether that’s safe depends entirely on what you’re rescuing, which is where Ruby’s exception class hierarchy comes in: every error in Ruby, from a typo’d method call to a low-level signal from the operating system, descends from a single root class called Exception. This lesson covers both together, because you cannot use retry responsibly without understanding exactly which branch of that hierarchy you’re catching.
Overview: How retry and the Exception Hierarchy Work
In Ruby, exceptions are ordinary objects, and exception classes are ordinary classes arranged in an inheritance tree rooted at Exception. When you write raise SomeError, "message", Ruby creates an instance of SomeError and starts unwinding the call stack, looking for a begin/rescue block whose rescue clause matches the raised object’s class — or one of its ancestors. “Matches” here means the same thing as SomeError === raised_object, which for classes checks is_a?, so a rescue StandardError clause will catch a ZeroDivisionError because ZeroDivisionError is a subclass of StandardError.
The hierarchy is split into two very different halves. Near the top sit exceptions that represent conditions the interpreter or the operating system needs to communicate — SystemExit (raised by Kernel#exit), SignalException and its subclass Interrupt (raised on Ctrl-C), NoMemoryError, and ScriptError (parse and load failures). You almost never want to rescue these; a program that swallows Interrupt can no longer be stopped from the terminal. Below all of that sits StandardError, the branch that covers ordinary, expected-to-happen runtime problems: bad arguments, missing hash keys, division by zero, failed I/O. This is why a bare rescue with no class name, or rescue => e, is shorthand for rescue StandardError => e — Ruby deliberately does not let you accidentally catch a signal or an out-of-memory condition just by forgetting to specify a class.
retry is only legal inside a rescue clause. Calling it discards the current exception and jumps back to the very first line of the enclosing begin block, running it again from scratch. Anything declared outside the begin block (like an attempt counter initialized before it) survives the jump and keeps its value; anything declared inside the block is recreated fresh on each pass. This makes retry perfect for recovering from problems that are likely transient — a dropped network connection, a database deadlock, a rate limit — where trying again after a moment has a real chance of succeeding.
Common StandardError subclasses
| Class | Superclass | Typical cause |
|---|---|---|
StandardError |
Exception |
Base class for ordinary runtime errors; the default rescue target |
ArgumentError |
StandardError |
Wrong number or type of arguments passed to a method |
TypeError |
StandardError |
An object is the wrong type and can’t be implicitly converted |
NameError |
StandardError |
Reference to an undefined local variable or constant |
NoMethodError |
NameError |
Calling a method that doesn’t exist on the receiver |
IndexError |
StandardError |
Invalid index into an ordered collection |
KeyError |
IndexError |
Hash#fetch called with a missing key |
RangeError |
StandardError |
Numeric value outside the allowed range |
ZeroDivisionError |
StandardError |
Integer division by zero |
IOError |
StandardError |
An I/O operation failed on a closed or invalid stream |
RuntimeError |
StandardError |
Generic error; what raise "message" uses when no class is given |
Syntax
The full form of the block, with every clause it supports:
begin
# code that might raise
rescue SpecificError => e
# handle SpecificError
retry if some_condition
rescue AnotherError => e
# handle AnotherError
else
# runs only if no exception was raised
ensure
# always runs, whether or not an exception occurred
end
begin/end— delimits the protected block whose code might raise.rescue ClassName => e— catches any exception thatis_a?(ClassName), binding it to local variablee. You can list a clause per class, or list several classes on one line separated by commas.retry— only valid inside arescueclause; jumps back to the top of thebeginbody and re-executes it.else— optional; runs only when thebeginbody completed with no exception at all.ensure— optional; runs exactly once when the whole block is finally exited, whether that’s by returning normally, by an exception escaping unhandled, or by an explicitreturn. It does not re-run on everyretry— only when the block truly finishes.
Examples
Example 1: Exploring the exception hierarchy
puts ArgumentError.ancestors.take(5).inspect
puts ZeroDivisionError.ancestors.take(5).inspect
Output:
[ArgumentError, StandardError, Exception, Object, Kernel]
[ZeroDivisionError, StandardError, Exception, Object, Kernel]
Every exception class carries its own ancestor chain, just like any other Ruby class. ArgumentError and ZeroDivisionError look unrelated at first glance, but both ultimately descend from StandardError then Exception then Object, which is exactly why a single rescue StandardError clause can catch either one.
Example 2: Using retry to recover from a transient error
def fetch_data(attempt_limit: 3)
attempts = 0
begin
attempts += 1
raise IOError, "connection reset" if attempts < 3
"data loaded on attempt #{attempts}"
rescue IOError => e
if attempts < attempt_limit
puts "Attempt #{attempts} failed: #{e.message}. Retrying..."
retry
else
puts "Giving up after #{attempts} attempts."
"no data"
end
end
end
puts fetch_data
Output:
Attempt 1 failed: connection reset. Retrying...
Attempt 2 failed: connection reset. Retrying...
data loaded on attempt 3
attempts is declared before begin, so it survives every retry and keeps counting up. On the first two passes the simulated failure condition (attempts < 3) is true, so an IOError is raised and rescued, and since attempts is still below attempt_limit the code calls retry, jumping back to the top of begin. On the third pass attempts is 3, the raise is skipped, and the block’s last expression — the success string — becomes the return value with no exception involved, so the rescue clause never runs that time.
Example 3: Custom exception hierarchy and rescue ordering
class ApplicationError < StandardError; end
class ValidationError < ApplicationError; end
class MissingFieldError < ValidationError
def initialize(field)
super("missing required field: #{field}")
@field = field
end
attr_reader :field
end
def validate(record)
raise MissingFieldError, "email" unless record.key?(:email)
"record valid"
end
[{ name: "Ada" }, { name: "Grace", email: "grace@example.com" }].each do |record|
begin
result = validate(record)
puts result
rescue MissingFieldError => e
puts "Missing field error: #{e.message} (field: #{e.field})"
rescue ValidationError => e
puts "Validation error: #{e.message}"
rescue ApplicationError => e
puts "Application error: #{e.message}"
end
end
Output:
Missing field error: missing required field: email (field: email)
record valid
This defines a three-level hierarchy rooted at a project-specific ApplicationError, itself a StandardError subclass (never inherit your own exceptions from bare Exception). MissingFieldError overrides initialize to call super with a formatted message and store the offending field name as extra data. The first record lacks :email, so validate raises MissingFieldError, which is caught by the first matching rescue clause — the most specific one. The second record is valid, so validate returns normally and no exception is ever raised.
Example 4: retry with a maximum attempt limit and re-raising
class TransientError < StandardError; end
def unreliable_operation(succeed_on:, calls:)
calls[:count] += 1
raise TransientError, "temporary glitch (call #{calls[:count]})" if calls[:count] < succeed_on
"success on call #{calls[:count]}"
end
def call_with_retries(max_attempts:, succeed_on:)
calls = { count: 0 }
attempts = 0
begin
attempts += 1
unreliable_operation(succeed_on: succeed_on, calls: calls)
rescue TransientError => e
puts "Rescued: #{e.message}"
if attempts < max_attempts
retry
else
puts "Exceeded #{max_attempts} attempts, re-raising"
raise
end
end
end
begin
puts call_with_retries(max_attempts: 5, succeed_on: 3)
rescue TransientError => e
puts "Final failure: #{e.message}"
end
begin
puts call_with_retries(max_attempts: 2, succeed_on: 10)
rescue TransientError => e
puts "Final failure: #{e.message}"
end
Output:
Rescued: temporary glitch (call 1)
Rescued: temporary glitch (call 2)
success on call 3
Rescued: temporary glitch (call 1)
Rescued: temporary glitch (call 2)
Exceeded 2 attempts, re-raising
Final failure: temporary glitch (call 2)
The first call succeeds on the third attempt, well within the limit of 5, so call_with_retries returns its success string normally. The second call sets a much lower ceiling (max_attempts: 2) against an operation that needs ten calls to succeed, so after the second rescue the attempt count is no longer below the maximum, and the else branch runs bare raise — which re-raises the exact exception currently being handled. That escapes call_with_retries entirely and is finally caught by the outer begin/rescue around the call site.
How It Works Step by Step
Tracing what happens when a begin/rescue/retry block runs:
- Ruby executes the statements inside
beginin order, just like normal code. - If one of them raises, Ruby stops immediately and walks the raised object’s ancestor chain against each
rescueclause, top to bottom, using the first one that matches. - The matching clause’s body runs, with the exception object available as
e(or whatever name you bound it to). - If that clause calls
retry, the exception is discarded and execution jumps back to the very first statement insidebegin, as if the block were starting over. Variables declared before the block keep their current value; variables declared inside it are reinitialized. - If no
retryis called, the block finishes: either the rescue clause’s last expression becomes the value of the wholebeginexpression, or an explicitraisesends the exception (the same one, or a new one) further up the call stack to the next enclosingbegin/rescue. - An
ensureclause, if present, runs exactly once at that final exit point — not on every intermediate retry — making it the right place for cleanup like closing a file or releasing a lock. - If an exception is never rescued anywhere up the call stack, it reaches the top level, Ruby prints a backtrace to
$stderr, and the program exits with a non-zero status.
Common Mistakes
Mistake 1: Rescuing Exception instead of StandardError
begin
perform_task
rescue Exception => e
puts "Something went wrong: #{e.message}"
end
Exception is the root of the entire hierarchy, including SystemExit and Interrupt. This snippet would silently swallow a user’s Ctrl-C and could hide a NoMemoryError that really should have crashed the process. Rescue StandardError (or a specific subclass) instead:
begin
perform_task
rescue StandardError => e
puts "Something went wrong: #{e.message}"
end
Mistake 2: An unbounded retry loop
begin
connect_to_service
rescue ConnectionError
retry
end
There’s no counter and no limit here. If the service is down for good, this becomes an infinite retry loop that never returns control to the caller and can hammer the remote service forever. Always track attempts and cap them:
attempts = 0
begin
attempts += 1
connect_to_service
rescue ConnectionError
if attempts < 3
retry
else
raise
end
end
Mistake 3: Rescue clauses in the wrong order
begin
validate_input(data)
rescue StandardError => e
puts "Error: #{e.message}"
rescue ArgumentError => e
puts "Bad argument: #{e.message}"
end
Ruby tries rescue clauses top to bottom and stops at the first match. Since ArgumentError is already a StandardError, the first clause catches it too, making the second clause dead code that can never run. Put the more specific class first:
begin
validate_input(data)
rescue ArgumentError => e
puts "Bad argument: #{e.message}"
rescue StandardError => e
puts "Error: #{e.message}"
end
Best Practices
- Rescue the narrowest class that makes sense; avoid bare
rescue Exceptionso you never accidentally swallowSystemExit,Interrupt, orNoMemoryError. - Always pair
retrywith an attempt counter and a maximum, so a permanent failure eventually gives up instead of looping forever. - Only retry errors you know are transient — network timeouts, lock contention, rate limits. Retrying an
ArgumentErrorcaused by a real bug just fails identically every time. - Give your application its own exception hierarchy rooted in a class like
ApplicationError < StandardError, so callers can rescue broadly or narrowly as needed. - Order
rescueclauses from most specific subclass to least specific superclass. - Use
ensurefor cleanup that must run regardless of outcome; remember it fires once at final exit, not on every retry pass. - Prefer bare
raise(no arguments) inside a rescue clause to re-raise the current exception unchanged, rather than constructing a new one and losing the original backtrace.
Practice Exercises
- Write a method
parse_number(str)that callsInteger(str)inside abegin/rescue ArgumentErrorblock, retries once with a stripped version of the string, and returnsnilif it still fails. - Define a small hierarchy for a library app:
LibraryError < StandardError, thenBookNotFoundErrorandBookUnavailableErrorboth inheriting from it. Write a method that raises the right one based on a condition, and a caller with rescue clauses ordered correctly to handle each. - Modify Example 4 so
call_with_retriesalso returns the total number of attempts made alongside its result. Then think through what would happen ifmax_attemptswere set to0— hint: thebeginbody always runs at least once before any limit is checked.
Summary
- Every Ruby exception descends from
Exception;StandardErroris the branch ordinaryrescueclauses should target, since it excludes system-level conditions likeSystemExitandInterrupt. - A bare
rescueorrescue => eimplicitly meansrescue StandardError => e. retryis only legal inside arescueclause; it discards the current exception and re-executes thebeginbody from the top, preserving variables declared outside the block.- Unbounded
retryis a real infinite-loop risk — always combine it with an attempt counter and a maximum. rescueclauses are tried top to bottom, so order them from most specific subclass to least specific superclass.- Custom exceptions should inherit from
StandardError(directly or through your own base error class) so they compose naturally with the rest of the hierarchy.
