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.
rescueis 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 orHash#fetchwith a default instead ofrescue. 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.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀