18 min read

Rails Exception Handling Best Practices: A Complete Guide

Everything you need to know about handling errors in Rails: from basic rescue blocks to production-grade error tracking strategies.

Exception handling is one of those areas where the gap between "it works" and "it works well" is enormous. Poor error handling leads to silent failures, frustrated users, and developers waking up to mystery bugs. Good error handling means you know about problems before your users do and have the context to fix them quickly.

This guide covers everything from Ruby basics to production-grade Rails error handling strategies.

Ruby Exception Basics

Before diving into Rails-specific patterns, let's review Ruby's exception handling fundamentals.

The Basic begin/rescue/end Block

begin
  # Code that might raise an exception
  result = risky_operation
rescue StandardError => e
  # Handle the exception
  logger.error "Operation failed: #{e.message}"
  # Optionally re-raise
  raise
end

The Exception Hierarchy

Ruby's exception hierarchy matters. Here's the important part:

Exception
├── NoMemoryError
├── SignalException
├── SystemExit
└── StandardError (rescue this one)
    ├── ArgumentError
    ├── RuntimeError
    ├── NoMethodError
    ├── TypeError
    └── ... (most exceptions you'll encounter)

Important: Always rescue StandardError, not Exception. Rescuing Exception catches things like SignalException and SystemExit, which breaks Ctrl+C and process management.

# Bad - catches too much
rescue Exception => e

# Good - catches application errors
rescue StandardError => e

# Also good - StandardError is the default
rescue => e

Multiple Rescue Clauses

You can rescue different exception types with different handlers:

begin
  api_response = external_api.fetch_data
rescue Timeout::Error
  # Handle timeout specifically
  retry_with_backoff
rescue JSON::ParserError => e
  # Handle malformed response
  log_and_return_default
rescue StandardError => e
  # Catch-all for unexpected errors
  report_to_error_tracker(e)
  raise
end

The ensure Clause

Code in ensure runs whether or not an exception was raised:

def process_file(path)
  file = File.open(path)
  process(file.read)
rescue IOError => e
  logger.error "Failed to read file: #{e.message}"
ensure
  file&.close  # Always runs
end

The else Clause

Code in else runs only if no exception was raised:

begin
  result = operation_that_might_fail
rescue OperationError => e
  handle_failure(e)
else
  # Only runs if no exception
  process_success(result)
end

Rails-Specific Error Handling

Common Rails Exceptions

Rails defines several exceptions you'll encounter regularly:

# Record not found (404)
ActiveRecord::RecordNotFound

# Validation failed
ActiveRecord::RecordInvalid

# Routing error (404)
ActionController::RoutingError

# Missing template
ActionView::MissingTemplate

# Parameter missing
ActionController::ParameterMissing

# CSRF token invalid
ActionController::InvalidAuthenticityToken

Rails Error Reporting API (Rails 7+)

Rails 7 introduced a unified error reporting API. This is the modern way to handle errors:

# Report and swallow the error
Rails.error.handle do
  might_fail
end

# Report and re-raise the error
Rails.error.record do
  might_fail
end

# Report manually
Rails.error.report(exception, handled: true)

You can subscribe to these errors with custom handlers:

# config/initializers/error_subscriber.rb
class ErrorSubscriber
  def report(error, handled:, severity:, context:, source: nil)
    MyErrorTracker.capture(error, context: context)
  end
end

Rails.error.subscribe(ErrorSubscriber.new)

Creating Custom Exception Classes

Custom exceptions make your code more expressive and errors easier to handle.

Basic Custom Exception

# app/errors/application_error.rb
class ApplicationError < StandardError
  attr_reader :code, :details

  def initialize(message = nil, code: nil, details: {})
    @code = code
    @details = details
    super(message)
  end
end

Domain-Specific Exceptions

# app/errors/payment_error.rb
class PaymentError < ApplicationError; end
class PaymentDeclinedError < PaymentError; end
class PaymentGatewayError < PaymentError; end
class InsufficientFundsError < PaymentError; end

# app/errors/authentication_error.rb
class AuthenticationError < ApplicationError; end
class InvalidCredentialsError < AuthenticationError; end
class AccountLockedError < AuthenticationError; end
class SessionExpiredError < AuthenticationError; end

Using Custom Exceptions

class PaymentService
  def charge(user, amount)
    response = gateway.charge(user.payment_method, amount)

    case response.status
    when :success
      response.transaction
    when :declined
      raise PaymentDeclinedError.new(
        "Card was declined",
        code: response.decline_code,
        details: { last_four: user.payment_method.last_four }
      )
    when :gateway_error
      raise PaymentGatewayError, "Gateway unavailable"
    end
  end
end

Controller-Level Handling with rescue_from

Rails controllers can handle exceptions declaratively with rescue_from.

Basic rescue_from

class ApplicationController < ActionController::Base
  rescue_from ActiveRecord::RecordNotFound, with: :not_found
  rescue_from ActionController::ParameterMissing, with: :bad_request

  private

  def not_found
    render json: { error: "Resource not found" }, status: :not_found
  end

  def bad_request(exception)
    render json: { error: exception.message }, status: :bad_request
  end
end

Comprehensive Error Handling

class ApplicationController < ActionController::Base
  rescue_from StandardError, with: :internal_error
  rescue_from ActiveRecord::RecordNotFound, with: :not_found
  rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
  rescue_from ActionController::ParameterMissing, with: :bad_request
  rescue_from AuthenticationError, with: :unauthorized
  rescue_from AuthorizationError, with: :forbidden

  private

  def not_found
    respond_to do |format|
      format.html { render "errors/404", status: :not_found }
      format.json { render json: { error: "Not found" }, status: :not_found }
    end
  end

  def unauthorized(exception)
    respond_to do |format|
      format.html { redirect_to login_path, alert: exception.message }
      format.json { render json: { error: exception.message }, status: :unauthorized }
    end
  end

  def internal_error(exception)
    # Log and track the error
    Rails.error.report(exception, handled: true)

    respond_to do |format|
      format.html { render "errors/500", status: :internal_server_error }
      format.json { render json: { error: "Internal server error" }, status: :internal_server_error }
    end
  end
end

Order Matters

rescue_from handlers are matched from bottom to top. Put more specific handlers after general ones:

# This order is correct
rescue_from StandardError, with: :internal_error          # Catch-all (checked last)
rescue_from PaymentError, with: :payment_error            # More specific
rescue_from PaymentDeclinedError, with: :payment_declined # Most specific (checked first)

Error Handling in Background Jobs

Background jobs need special attention because there's no user waiting for a response.

Active Job Error Handling

class ImportDataJob < ApplicationJob
  retry_on Timeout::Error, wait: :exponentially_longer, attempts: 5
  discard_on ActiveRecord::RecordNotFound

  def perform(import_id)
    import = Import.find(import_id)
    ImportService.new(import).process
  rescue ImportService::ValidationError => e
    # Don't retry validation errors
    import.update!(status: :failed, error_message: e.message)
  rescue StandardError => e
    # Track unexpected errors before retrying
    Rails.error.report(e, context: { import_id: import_id })
    raise  # Re-raise to trigger retry
  end
end

Sidekiq-Specific Patterns

class ProcessOrderJob
  include Sidekiq::Job

  sidekiq_options retry: 5

  sidekiq_retry_in do |count, exception|
    case exception
    when RateLimitError
      60 * (count + 1)  # Linear backoff for rate limits
    else
      (count ** 4) + 15  # Exponential backoff for others
    end
  end

  sidekiq_retries_exhausted do |job, exception|
    order_id = job['args'].first
    Order.find(order_id).update!(status: :failed)
    AdminMailer.job_failed(job, exception).deliver_later
  end

  def perform(order_id)
    ProcessOrderService.new(order_id).call
  end
end

Setting Up Error Tracking

In production, you need visibility into errors as they happen. This is where error tracking tools come in.

What to Look For

A good error tracking setup should provide:

  • Automatic capture: Unhandled exceptions should be tracked automatically
  • Context: Request parameters, user info, and environment details
  • Local variables: Variable values at the point of failure
  • Grouping: Similar errors should be grouped together
  • Notifications: Get alerted when new errors occur

Setting Up Faultline (Self-Hosted)

For Rails applications, Faultline provides all of these features with zero external dependencies:

# Gemfile
gem "faultline", git: "https://github.com/dlt/faultline.git"
$ rails generate faultline:install
$ rails db:migrate

Configure authentication and notifications:

# config/initializers/faultline.rb
Faultline.configure do |config|
  # Restrict dashboard access
  config.authenticate_with = lambda { |request|
    request.env["warden"]&.user&.admin?
  }

  # Add Slack notifications
  config.add_notifier(
    Faultline::Notifiers::Slack.new(
      webhook_url: Rails.application.credentials.slack_webhook,
      channel: "#errors"
    )
  )

  # Add custom context to every error
  config.custom_context = lambda { |request, env|
    controller = env["action_controller.instance"]
    {
      current_user_id: controller&.current_user&.id,
      request_id: request.request_id
    }
  }
end

Faultline automatically captures local variables at the raise point, so you can see exactly what values caused the error:

def process_order(user, items)
  total = calculate_total(items)
  discount = user.discount_percentage  # If user is nil...
  final_price = total * (1 - discount / 100.0)
end

# Faultline captures: user=nil, items=[...], total=150.0

Manual Error Reporting

Sometimes you want to track errors without raising them:

def sync_external_data
  response = ExternalAPI.fetch

  if response.partial_failure?
    # Track the issue but continue
    Faultline.track(
      ExternalAPI::PartialFailure.new(response.errors),
      custom_data: {
        successful_records: response.success_count,
        failed_records: response.failure_count
      }
    )
  end

  process_successful_records(response.data)
end

Best Practices Summary

Do:

  • Rescue specific exceptions when you know how to handle them
  • Create custom exception classes for domain-specific errors
  • Use rescue_from in controllers for clean error responses
  • Add context when reporting errors (user ID, request ID, relevant data)
  • Use Rails.error.report for handled errors you want to track
  • Configure retry strategies for background jobs
  • Set up error tracking before your first production deploy
  • Test your error handling with intentional failures

Don't:

  • Rescue Exception—always use StandardError
  • Silently swallow errors with empty rescue blocks
  • Log and forget—make errors actionable
  • Expose internal details in user-facing error messages
  • Retry indefinitely without backoff and limits
  • Ignore errors in background jobs—they're just as important

The Golden Rule

If you can handle an error gracefully, do so. If you can't, make sure it gets tracked and you get notified. Never let errors disappear silently.


Conclusion

Good exception handling is the difference between debugging for hours and knowing exactly what went wrong in seconds. The time invested in proper error handling pays dividends every time something goes wrong in production.

Start with the basics—specific rescues, custom exceptions, and rescue_from in controllers. Then add proper error tracking so you're never flying blind. Your future self (and your on-call rotation) will thank you.