Error Handling in Ruby — Beyond Rescue and Raise
by Eric Hanson, Backend Developer at Clean Systems Consulting
The conflation problem
Ruby's rescue catches exceptions. Exceptions are the right mechanism for unexpected failures — a database connection that drops, a third-party API returning a 500, a file that can't be opened. They're the wrong mechanism for expected outcomes of business logic — a payment that gets declined, a user who doesn't have permission, a cart item that's out of stock.
When every failure path raises an exception, three things happen. Controllers accumulate rescue blocks that distinguish between business failures and system errors by exception type — a fragile convention. Error hierarchies sprawl to accommodate every new failure mode. And callers can't tell from a method signature whether it raises or returns a value.
The fix isn't to stop using exceptions. It's to be deliberate about which failures are exceptional.
Build a meaningful exception hierarchy
Ruby's standard library uses a two-branch hierarchy: Exception at the root, with StandardError covering expected runtime errors and SignalException/ScriptError covering things that shouldn't be rescued in application code. Follow the same convention for your own exceptions:
module MyApp
# Base class — rescue this to catch any app-level error
class Error < StandardError; end
# Domain errors — expected, recoverable
module Errors
class ValidationError < MyApp::Error; end
class AuthorizationError < MyApp::Error; end
class NotFoundError < MyApp::Error; end
# Subtypes for specific cases
class PaymentDeclinedError < MyApp::Error
attr_reader :decline_code
def initialize(msg = "payment declined", decline_code:)
@decline_code = decline_code
super(msg)
end
end
end
# Infrastructure errors — unexpected, usually fatal to the request
module InfrastructureErrors
class DatabaseError < MyApp::Error; end
class ExternalApiError < MyApp::Error; end
end
end
The separation matters because you handle these differently. Domain errors get rendered as user-facing messages. Infrastructure errors get logged, alerted, and often re-raised so they surface in Sentry or Honeybadger. Rescuing MyApp::Error catches both — rescuing MyApp::Errors::ValidationError is surgical.
Don't inherit directly from Exception. Doing so bypasses Ruby's default rescue behavior — bare rescue only catches StandardError and its descendants. Custom exceptions inheriting from Exception won't be caught by rescue clauses without an explicit type argument, which is almost never what you want.
retry with backoff
retry re-executes the enclosing begin/rescue block from the top. It's the right tool for transient failures — network timeouts, rate limit responses, database deadlocks — but only with a bounded attempt count and backoff:
def fetch_with_retry(url, max_attempts: 3)
attempts = 0
begin
attempts += 1
HTTP.get(url)
rescue HTTP::TimeoutError, HTTP::ConnectionError => e
raise if attempts >= max_attempts
sleep(2 ** attempts) # 2s, 4s, 8s
retry
end
end
The raise without arguments inside the rescue re-raises the current exception — here it fires when attempts >= max_attempts, propagating the original error to the caller. Without that guard, retry loops forever on a permanently unavailable endpoint.
For production use, add jitter to the backoff to avoid thundering herd problems when multiple processes simultaneously hit the same failure:
sleep(2 ** attempts + rand(0.5))
The retry keyword is specific to rescue blocks inside begin...end, def, or block forms. You can't call it outside a rescue clause. If you find yourself wanting to retry outside that context, you want a loop with an explicit break condition instead.
ensure for guaranteed cleanup
ensure runs whether or not an exception was raised, and whether or not it was rescued. It's the Ruby equivalent of finally in other languages — the right place for resource cleanup:
def process_file(path)
file = File.open(path)
parse(file.read)
rescue ParseError => e
log_error(e)
nil
ensure
file&.close
end
The safe navigation operator (&.) handles the case where File.open itself raised before file was assigned. Without it, file.close in ensure raises a NoMethodError on nil.
Note that ensure does not suppress exceptions. If the rescue block re-raises or doesn't catch the exception, the exception still propagates after ensure runs. And if ensure itself raises, that new exception replaces the original — a common source of "swallowed" errors. Keep ensure blocks simple and side-effect-free beyond cleanup.
The idiomatic alternative for file-like resources is the block form, which handles cleanup internally:
File.open(path) do |file|
parse(file.read)
end
IO#open calls close in its own ensure block. Prefer the block form whenever the API supports it — it's less code and harder to get wrong.
raise with cause
When you catch a low-level exception and re-raise a higher-level one, Ruby automatically preserves the original as cause on the new exception:
def load_config(path)
JSON.parse(File.read(path))
rescue JSON::ParserError => e
raise MyApp::Error, "invalid config at #{path}"
# e is automatically set as the cause of the new exception
end
The caller sees MyApp::Error. But exception.cause holds the original JSON::ParserError, which your error tracker will log. This means you get clean, domain-meaningful exception types without losing the root cause. Check your Sentry or Honeybadger setup to confirm it follows cause chains — most do by default.
To explicitly set a different cause:
raise MyApp::Error, "something failed", cause: original_exception
Rescuing multiple exception types
Rescue clauses accept multiple types separated by commas:
rescue Timeout::Error, Net::OpenTimeout, Net::ReadTimeout => e
# handle any network timeout
Rescue clauses are checked in order, top to bottom. More specific subtypes must come before their parents:
rescue MyApp::Errors::PaymentDeclinedError => e
render_decline_message(e.decline_code)
rescue MyApp::Error => e
render_generic_error(e.message)
If MyApp::Error appeared first, it would catch PaymentDeclinedError before the specific handler ran — the specific branch would be dead code. Linters like RuboCop with the rubocop-rails extension flag this ordering mistake.
Avoid rescuing StandardError directly in application code. It catches NoMethodError, ArgumentError, and TypeError — failures that indicate programming errors, not runtime conditions. Rescuing them silently buries bugs. If you need a safety net, rescue your own base class (MyApp::Error) and let everything else propagate.
Caller-aware error context with structured exceptions
Plain string messages in exceptions are hard to programmatically inspect. When an error needs to carry structured context — for logging, for rendering, for conditional handling — add attributes to the exception class:
class ApiError < MyApp::Error
attr_reader :status_code, :response_body, :request_id
def initialize(msg = "API request failed", status_code:, response_body: nil, request_id: nil)
@status_code = status_code
@response_body = response_body
@request_id = request_id
super(msg)
end
end
# Raising
raise ApiError.new(status_code: 422, response_body: body, request_id: headers["X-Request-Id"])
# Rescuing
rescue ApiError => e
Sentry.capture_exception(e, extra: { request_id: e.request_id, status: e.status_code })
render_error(e.message)
The exception carries its own diagnostic context rather than relying on the surrounding code to reconstruct it. This pays off in error trackers where the exception is the unit of investigation — you have everything you need in one place.
The two-layer model in practice
In a Rails application, the pattern that holds up at scale is two layers of error handling:
Service layer: raises domain exceptions for expected failures, lets infrastructure exceptions propagate naturally.
Controller layer (or rescue_from): catches domain exceptions and renders appropriate responses; lets infrastructure exceptions reach the default error handler.
class ApplicationController < ActionController::Base
rescue_from MyApp::Errors::AuthorizationError, with: :render_forbidden
rescue_from MyApp::Errors::NotFoundError, with: :render_not_found
rescue_from MyApp::Errors::ValidationError, with: :render_unprocessable
private
def render_forbidden(e)
render json: { error: e.message }, status: :forbidden
end
def render_not_found(e)
render json: { error: e.message }, status: :not_found
end
def render_unprocessable(e)
render json: { error: e.message }, status: :unprocessable_entity
end
end
Individual controllers stay clean — no rescue blocks, no type-checking on exceptions. The global handler maps domain errors to HTTP responses. Infrastructure errors fall through to Rails' default 500 handling and your error tracker.
The hierarchy, the two layers, and the structured exceptions are the durable parts of this. Everything else — retry strategies, ensure cleanup, cause chaining — is in service of those three.