/ Tags: RUBY 3 / Categories: RUBY

Exception Handling in Ruby — rescue, ensure, retry, and raise Done Right

Exception handling is one of those topics that feels simple until you hit a subtle bug caused by rescuing too broadly, retrying without a limit, or swallowing errors that should surface. Ruby’s exception model is expressive — rescue, ensure, retry, raise, and a well-structured exception hierarchy — and it’s worth understanding in depth rather than copying patterns that mostly work.

The Exception Hierarchy


Ruby’s exception hierarchy determines what rescue catches. At the top is Exception, which you almost never want to rescue. Below it is StandardError, which is what rescue catches by default when you don’t specify a class.

Example:

begin
  risky_operation
rescue => e        # catches StandardError and all subclasses
  puts e.message
end

The hierarchy worth knowing:

Exception
  └── StandardError        ← rescue default
        ├── RuntimeError   ← raise "message" default
        ├── ArgumentError
        ├── TypeError
        ├── IOError
        ├── KeyError
        ├── NoMethodError
        ├── NameError
        └── ...
  ├── ScriptError
  ├── SignalException        ← Interrupt lives here
  └── SystemExit

SignalException and SystemExit are not StandardError subclasses — which is why rescue => e doesn’t catch Ctrl+C or exit. Rescuing Exception would catch those, which is almost always a mistake.

rescue — Specific Is Better


Example:

# Too broad — catches everything including bugs you should fix
begin
  User.find(params[:id])
rescue => e
  render json: { error: "Something went wrong" }
end

# Specific — catches exactly what you expect
begin
  User.find(params[:id])
rescue ActiveRecord::RecordNotFound => e
  render json: { error: "User not found" }, status: :not_found
end

The broad rescue hides bugs. If params[:id] is nil and triggers a different error, the broad rescue swallows it and returns “something went wrong” — you’ll never see it in your logs as the distinct error it is.

Multiple rescue clauses handle different cases:

Example:

begin
  result = ExternalAPI.fetch(id)
rescue Faraday::TimeoutError
  render json: { error: "Request timed out" }, status: :gateway_timeout
rescue Faraday::ConnectionFailed => e
  render json: { error: "Service unavailable" }, status: :service_unavailable
rescue JSON::ParserError => e
  Rails.logger.error("Invalid JSON from API: #{e.message}")
  render json: { error: "Invalid response" }, status: :bad_gateway
end

Ruby checks rescue clauses in order and uses the first match. Put more specific exceptions before broader ones.

ensure — Always Runs


ensure runs regardless of whether an exception was raised — it’s for cleanup that must happen no matter what:

Example:

def process_file(path)
  file = File.open(path)
  process(file.read)
rescue IOError => e
  log_error(e)
  raise
ensure
  file&.close  # always closes, even if process raised
end

ensure runs after rescue handlers, and after re-raises. It does not suppress exceptions — if a rescue re-raises with raise, the exception still propagates after ensure runs.

Common uses: closing file handles, releasing database connections, stopping a spinner, logging request completion.

retry — Limit Your Attempts


retry re-executes the begin block from the start. Without a counter, it loops forever on persistent failures:

Example:

# DANGEROUS — infinite loop on persistent failure
begin
  result = unreliable_api.call
rescue Faraday::TimeoutError
  retry
end

Always limit retries:

Example:

attempts = 0

begin
  attempts += 1
  result = unreliable_api.call
rescue Faraday::TimeoutError => e
  raise if attempts >= 3
  sleep(2 ** attempts)  # exponential backoff: 2s, 4s
  retry
end

Exponential backoff with jitter prevents thundering herd — all clients retrying simultaneously can overwhelm the service you’re retrying against.

raise and Custom Exceptions


raise with no arguments re-raises the current exception, preserving the original backtrace. Use this inside rescue when you want to log something but let the caller handle it:

Example:

begin
  dangerous_operation
rescue => e
  Rails.logger.error("Operation failed: #{e.message}")
  raise  # re-raises the original exception
end

raise SomeError, "message" raises a new exception. raise with no arguments in a rescue block re-raises the current exception.

Custom exception classes make error handling meaningful:

Example:

module PaymentError
  class Base < StandardError; end
  class InsufficientFunds < Base; end
  class CardDeclined < Base; end
  class NetworkError < Base; end
end

class PaymentService
  def charge(amount)
    response = gateway.charge(amount)
    raise PaymentError::InsufficientFunds, "Balance too low" if response.insufficient_funds?
    raise PaymentError::CardDeclined, response.decline_message if response.declined?
    response
  rescue Faraday::Error => e
    raise PaymentError::NetworkError, "Gateway unreachable: #{e.message}"
  end
end

# Caller can rescue specifically or broadly
begin
  PaymentService.new.charge(100)
rescue PaymentError::InsufficientFunds
  flash[:error] = "Please add funds and try again"
rescue PaymentError::Base => e
  flash[:error] = "Payment failed: #{e.message}"
  notify_ops(e)
end

The hierarchy means rescue PaymentError::Base catches all payment errors; rescue PaymentError::InsufficientFunds catches only that specific case. You control granularity.

Pro-Tip: Don’t use exceptions for control flow. rescue is expensive — exceptions in Ruby involve allocating an object and capturing a backtrace. If a condition is expected (a user submits an invalid form, a hash key might not exist), use a predicate method or Hash#fetch with a default instead of rescue. Reserve exceptions for genuinely unexpected failures at system boundaries.

Method-Level rescue


In a method body, you can skip begin/end entirely and rescue at the method level:

Example:

def fetch_user(id)
  User.find(id)
rescue ActiveRecord::RecordNotFound
  nil
end

Cleaner for simple cases — the entire method body is the implicit begin block.

Conclusion


Effective exception handling comes down to specificity: rescue the exceptions you expect, at the level you can meaningfully handle them, with cleanup in ensure for resources that must be released. Broad rescues hide bugs. Unlimited retries create infinite loops. Swallowed exceptions make debugging a nightmare. Ruby’s exception model is expressive enough to handle all of this cleanly — the skill is knowing when to be precise and when to let errors propagate to a layer that can actually deal with them.

FAQs


Q1: Should I rescue Exception or StandardError?
StandardError. Rescuing Exception catches SignalException, Interrupt, and SystemExit — signals that should kill your process. You almost never want to suppress those. StandardError (the default) is almost always correct.

Q2: How do I get the exception message and backtrace?
e.message returns the error message. e.backtrace returns an array of strings (file:line:in method format). e.backtrace.first gives the immediate call site. In Rails logs, Rails.logger.error(e.full_message) includes both message and backtrace.

Q3: What’s the difference between raise and fail?
They’re identical aliases — same behavior, same performance. fail is sometimes preferred for signaling “this should not happen” semantically, while raise is used for surfacing expected error conditions. In practice most codebases use raise exclusively.

Q4: Can I rescue multiple exception types in one clause?
Yes: rescue TypeError, ArgumentError => e rescues either. Useful when different exceptions deserve the same handling. For different handling, use separate rescue clauses.

Q5: How do I test that code raises a specific exception?
In RSpec: expect { risky_code }.to raise_error(SomeError, /message pattern/). In Minitest: assert_raises(SomeError) { risky_code }. Test the specific exception class, not StandardError — same principle as production rescue code.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More