retry and Exception Hierarchies

Published by Programming Line in Ruby. Last updated .

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 that is_a?(ClassName), binding it to local variable e. You can list a clause per class, or list several classes on one line separated by commas.
  • retry — only valid inside a rescue clause; jumps back to the top of the begin body and re-executes it.
  • else — optional; runs only when the begin body 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 explicit return. It does not re-run on every retry — 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 begin in order, just like normal code.
  • If one of them raises, Ruby stops immediately and walks the raised object’s ancestor chain against each rescue clause, 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 inside begin, as if the block were starting over. Variables declared before the block keep their current value; variables declared inside it are reinitialized.
  • If no retry is called, the block finishes: either the rescue clause’s last expression becomes the value of the whole begin expression, or an explicit raise sends the exception (the same one, or a new one) further up the call stack to the next enclosing begin/rescue.
  • An ensure clause, 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 Exception so you never accidentally swallow SystemExit, Interrupt, or NoMemoryError.
  • Always pair retry with 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 ArgumentError caused 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 rescue clauses from most specific subclass to least specific superclass.
  • Use ensure for 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 calls Integer(str) inside a begin/rescue ArgumentError block, retries once with a stripped version of the string, and returns nil if it still fails.
  • Define a small hierarchy for a library app: LibraryError < StandardError, then BookNotFoundError and BookUnavailableError both 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_retries also returns the total number of attempts made alongside its result. Then think through what would happen if max_attempts were set to 0 — hint: the begin body always runs at least once before any limit is checked.

Summary

  • Every Ruby exception descends from Exception; StandardError is the branch ordinary rescue clauses should target, since it excludes system-level conditions like SystemExit and Interrupt.
  • A bare rescue or rescue => e implicitly means rescue StandardError => e.
  • retry is only legal inside a rescue clause; it discards the current exception and re-executes the begin body from the top, preserving variables declared outside the block.
  • Unbounded retry is a real infinite-loop risk — always combine it with an attempt counter and a maximum.
  • rescue clauses 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.