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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

REST Is Not Just Using HTTP. Here Is What It Actually Means.

Most APIs labeled “REST” ignore the constraints that actually define it. Understanding what REST really requires leads to more scalable, evolvable systems—but also reveals when not to use it.

Read more

The Hidden Cost of Over-Managed Developer Teams

At first, more control feels safer—more meetings, more approvals, more tracking. But slowly, productivity drops, and no one can quite explain why.

Read more

How a Tech Lead Prevents Knowledge Silos and Technical Debt

Projects stall, bugs pile up, and only a few people understand critical systems. A strong tech lead ensures knowledge is shared and technical debt stays manageable.

Read more

How to Avoid Misunderstandings With Remote Clients

Remote work can make communication tricky. Here’s how to keep your projects clear and clients on the same page.

Read more