Ruby Error Handling: rescue, retry, and Custom Exceptions
There’s a pattern that shows up in almost every Ruby codebase eventually:
def fetch_user(id)
User.find(id)
rescue => e
nil
end
This is the error-handling equivalent of sweeping dirt under a rug. The error is caught, silenced, and replaced with nil, which then drifts through your application until something blows up in an entirely different place. The stack trace points to the wrong location, the original error message is gone, and you’re left guessing.
Ruby has excellent error handling tools. The problem isn’t the language, it’s the habits we fall into when using rescue.
rescue is not a safety net
The most common mistake is rescuing too broadly. rescue => e catches every StandardError subclass, which includes things you almost certainly didn’t intend to catch:
begin
user = User.find(params[:id])
send_notification(user)
rescue => e
Rails.logger.error("Something went wrong: #{e.message}")
end
If User.find raises ActiveRecord::RecordNotFound, that gets caught. Fine, maybe that’s what you wanted. But if send_notification raises a NoMethodError because of a typo in your code, that gets caught too. Silently. You’ll see “Something went wrong: undefined method ‘emal’ for User” in your logs and wonder why notifications stopped working.
Rescue the specific error you expect:
begin
user = User.find(params[:id])
send_notification(user)
rescue ActiveRecord::RecordNotFound
Rails.logger.warn("User #{params[:id]} not found, skipping notification")
end
Now a NoMethodError propagates normally, hits your error tracker, and gets fixed. The RecordNotFound is handled deliberately with a clear message about what happened and why it’s okay.
The rescue modifier: use it sparingly
Ruby lets you rescue on a single line:
value = JSON.parse(input) rescue nil
This is tempting because it’s concise. It’s also dangerous because it rescues StandardError, you can’t specify which exception to catch, and you can’t log anything. If input is nil itself (not just invalid JSON), the NoMethodError gets swallowed.
The inline rescue is fine for truly trivial cases where you understand every possible failure mode. For anything involving external input, network calls, or complex logic, use the block form.
Custom exceptions: when and why
Ruby’s built-in exception hierarchy covers generic cases, but your application has domain-specific failure modes that deserve their own types. Custom exceptions let you distinguish between what went wrong without parsing error messages.
module Payments
class Error < StandardError; end
class CardDeclined < Error; end
class InsufficientFunds < Error; end
class GatewayTimeout < Error; end
end
Now calling code can handle failures at whatever granularity makes sense:
def process_payment(order)
gateway.charge(order.total, order.card_token)
rescue Payments::CardDeclined
order.update(status: "declined")
notify_customer(order, :card_declined)
rescue Payments::GatewayTimeout
ProcessPaymentJob.perform_in(5.minutes, order.id)
rescue Payments::Error => e
# Catch-all for payment errors we haven't specifically handled
Rails.logger.error("Payment failed for order #{order.id}: #{e.message}")
order.update(status: "failed")
end
Each failure gets an appropriate response. A declined card tells the customer. A timeout retries later. An unknown payment error logs and fails. Without custom exceptions, you’d be pattern-matching on error messages, which is brittle and unreadable.
Adding context to custom exceptions
Exceptions can carry data beyond just a message:
module Payments
class CardDeclined < Error
attr_reader :decline_code, :card_last_four
def initialize(message, decline_code:, card_last_four:)
@decline_code = decline_code
@card_last_four = card_last_four
super(message)
end
end
end
raise Payments::CardDeclined.new(
"Card ending in 4242 was declined",
decline_code: "insufficient_funds",
card_last_four: "4242"
)
The rescue handler can then make decisions based on structured data instead of parsing strings. This is especially valuable when exceptions cross system boundaries, like from a service object to a controller.
retry: automatic recovery
retry re-executes the begin block from the top. It’s powerful for transient failures, things like network timeouts, rate limits, and lock contention:
def fetch_from_api(url)
attempts = 0
begin
attempts += 1
HTTP.get(url)
rescue HTTP::TimeoutError
retry if attempts < 3
raise # give up after 3 attempts
end
end
Without the counter, retry creates an infinite loop. Always cap your retries.
Exponential backoff
For rate limits and overloaded services, retrying immediately just makes things worse. Add a delay that increases with each attempt:
def fetch_with_backoff(url)
attempts = 0
begin
attempts += 1
HTTP.get(url)
rescue HTTP::TooManyRequests, HTTP::TimeoutError
if attempts < 5
sleep(2 ** attempts) # 2, 4, 8, 16 seconds
retry
end
raise
end
end
This is a common enough pattern that you’ll want to extract it into a utility if you’re doing it in more than a couple of places. But start with the explicit version so you understand what’s happening before reaching for a gem.
ensure: cleanup that always runs
ensure runs whether the block succeeded or raised. It’s Ruby’s equivalent of a finally block:
def process_file(path)
file = File.open(path)
parse(file.read)
rescue Errno::ENOENT
Rails.logger.warn("File not found: #{path}")
nil
ensure
file&.close
end
The file gets closed regardless of whether parsing succeeds, the file is missing, or some other exception flies through. The &. safe navigation handles the case where the file was never opened.
A common mistake is putting return values in ensure. The ensure block’s return value is not used as the method’s return value (unless you explicitly use return, which you shouldn’t). The method returns whatever the begin or rescue block returned.
def bad_example
raise "oops"
rescue
"recovered" # this is the return value
ensure
"cleanup" # this is NOT the return value
end
bad_example # => "recovered"
The exception hierarchy
Understanding Ruby’s exception hierarchy helps you rescue at the right level:
Exception
├── NoMemoryError
├── SignalException (including Interrupt)
├── ScriptError (SyntaxError, LoadError)
└── StandardError ← rescue without arguments catches here
├── RuntimeError ← raise "message" creates this
├── NameError
│ └── NoMethodError
├── TypeError
├── ArgumentError
├── IOError
└── ... (many more)
rescue without a class catches StandardError and its subclasses. This is deliberate: SignalException (Ctrl+C), NoMemoryError, and SystemExit are not caught, because rescuing those would prevent your program from stopping when it should.
Never rescue Exception unless you have a very specific reason and re-raise immediately after logging:
# DON'T do this
rescue Exception => e
log(e)
# your program can't be interrupted now
end
# If you must, re-raise
rescue Exception => e
log(e)
raise
end
Putting it together: a real-world example
Here’s a service object that imports data from a CSV, handling errors at the right level:
class CsvImporter
class ImportError < StandardError; end
class InvalidRow < ImportError
attr_reader :row_number
def initialize(message, row_number:)
@row_number = row_number
super("Row #{row_number}: #{message}")
end
end
def import(file_path)
rows = CSV.read(file_path, headers: true)
results = { imported: 0, skipped: [] }
rows.each.with_index(1) do |row, index|
begin
process_row(row, index)
results[:imported] += 1
rescue InvalidRow => e
results[:skipped] << { row: e.row_number, reason: e.message }
end
end
results
rescue Errno::ENOENT
raise ImportError, "File not found: #{file_path}"
rescue CSV::MalformedCSVError => e
raise ImportError, "Invalid CSV: #{e.message}"
end
private
def process_row(row, index)
email = row["email"] || raise(InvalidRow.new("missing email", row_number: index))
name = row["name"] || raise(InvalidRow.new("missing name", row_number: index))
User.create!(email: email, name: name)
rescue ActiveRecord::RecordInvalid => e
raise InvalidRow.new(e.message, row_number: index)
end
end
Invalid rows are skipped and collected. File-level errors are wrapped in domain-specific exceptions. ActiveRecord validation failures are translated into InvalidRow errors with the row number attached. The caller gets a structured result they can act on.
The rule of thumb
Handle errors at the level where you have enough context to do something meaningful about them. If you can’t do anything useful, don’t rescue, let the error propagate to somewhere that can.
A controller can show the user a message. A background job can retry or dead-letter. A service object can translate low-level errors into domain errors. But a utility method three layers deep? It usually has no business catching exceptions. Let them bubble up.
The best error handling, like the best code, makes the program’s behavior obvious when you read it. Rescue specific errors. Add context when re-raising. Use custom exceptions to model your domain’s failure modes. And never, ever rescue Exception.
Debugging a test failure with mismatched Ruby hashes? Try RubyHash, paste your expected and actual hashes for a clean, side-by-side diff.
Enjoyed this post?
Subscribe to get notified when we publish more Ruby and Rails content.