← Back to blog

Ruby Error Handling: rescue, retry, and Custom Exceptions

· Lachlan Young

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.