Rails 8 Error Handling Done Right

Every Rails application encounters errors. Database connections fail, external APIs timeout, users submit invalid data, and records disappear. The difference between a frustrating application and a professional one lies in how these errors surface to users and developers.

This guide covers building a comprehensive error handling strategy: custom exception classes, controller-level rescues, error reporting, and user-friendly error pages that maintain brand consistency.

Custom Exception Classes

Rails provides standard exceptions, but domain-specific exceptions communicate intent more clearly. Build a hierarchy of custom exceptions that map to application concepts.

# lib/errors/application_error.rb
module Errors
  class ApplicationError < StandardError
    attr_reader :code, :details

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

    def to_h
      {
        error: self.class.name.demodulize.underscore,
        message: message,
        code: code,
        details: details
      }.compact
    end
  end

  # Resource errors
  class RecordNotAccessible < ApplicationError
    def initialize(resource_type, resource_id)
      super(
        "#{resource_type} not accessible",
        code: "resource_forbidden",
        details: { resource_type: resource_type, resource_id: resource_id }
      )
    end
  end

  # Business logic errors
  class InsufficientBalance < ApplicationError
    def initialize(required:, available:)
      super(
        "Insufficient balance: required #{required}, available #{available}",
        code: "insufficient_funds",
        details: { required: required, available: available }
      )
    end
  end

  # External service errors
  class PaymentGatewayError < ApplicationError
    def initialize(gateway_response)
      super(
        "Payment processing failed",
        code: "payment_failed",
        details: { gateway_code: gateway_response[:code] }
      )
    end
  end

  # Rate limiting
  class RateLimitExceeded < ApplicationError
    def initialize(limit:, window:, retry_after:)
      super(
        "Rate limit exceeded",
        code: "rate_limited",
        details: { limit: limit, window: window, retry_after: retry_after }
      )
    end
  end
end

Load these exceptions in the application configuration:

# config/application.rb
config.autoload_paths << Rails.root.join("lib")

Controller-Level Error Handling

Centralize error handling in ApplicationController to ensure consistent responses across the application. Different response formats require different handling strategies.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  rescue_from Errors::ApplicationError, with: :handle_application_error
  rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
  rescue_from ActiveRecord::RecordInvalid, with: :handle_validation_error
  rescue_from ActionController::ParameterMissing, with: :handle_bad_request

  private

  def handle_application_error(exception)
    log_error(exception)

    status = error_status_for(exception)

    respond_to do |format|
      format.html { render_error_page(status, exception.message) }
      format.json { render json: exception.to_h, status: status }
      format.turbo_stream { render_turbo_error(exception.message) }
    end
  end

  def handle_not_found(exception)
    respond_to do |format|
      format.html { render_error_page(:not_found, "Resource not found") }
      format.json { render json: { error: "not_found" }, status: :not_found }
      format.turbo_stream { render_turbo_error("Resource not found") }
    end
  end

  def handle_validation_error(exception)
    respond_to do |format|
      format.html { render_error_page(:unprocessable_entity, exception.message) }
      format.json do
        render json: {
          error: "validation_failed",
          details: exception.record.errors.to_hash
        }, status: :unprocessable_entity
      end
      format.turbo_stream { render_turbo_error(exception.record.errors.full_messages.join(", ")) }
    end
  end

  def handle_bad_request(exception)
    respond_to do |format|
      format.html { render_error_page(:bad_request, "Missing required parameter") }
      format.json { render json: { error: "bad_request", param: exception.param }, status: :bad_request }
    end
  end

  def error_status_for(exception)
    case exception
    when Errors::RecordNotAccessible then :forbidden
    when Errors::RateLimitExceeded then :too_many_requests
    when Errors::PaymentGatewayError then :payment_required
    else :unprocessable_entity
    end
  end

  def render_error_page(status, message)
    @error_message = message
    render "errors/show", status: status, layout: "error"
  end

  def render_turbo_error(message)
    render turbo_stream: turbo_stream.update(
      "flash",
      partial: "shared/flash",
      locals: { alert: message }
    )
  end

  def log_error(exception)
    Rails.logger.error([
      "ApplicationError: #{exception.class}",
      "Message: #{exception.message}",
      "Code: #{exception.code}",
      "Details: #{exception.details}",
      "User: #{current_user&.id}",
      "Path: #{request.path}"
    ].join(" | "))
  end
end

User-Friendly Error Pages

Default Rails error pages break the user experience. Custom error pages maintain brand consistency and provide helpful guidance.

# app/views/layouts/error.html.erb


  
    Something went wrong | <%= Rails.application.class.module_parent_name %>
    <%= stylesheet_link_tag "application", data_turbo_track: "reload" %>
  
  
    
<%= yield %>
# app/views/errors/show.html.erb

Something went wrong

<%= @error_message %>

<%= link_to "Go back", :back, class: "btn btn-secondary" %> <%= link_to "Home", root_path, class: "btn btn-primary" %>
<% if Rails.env.development? %> Debug Info
<%= @exception&.backtrace&.first(10)&.join("\n") %>
<% end %>

Route error pages through the application to maintain session context and styling:

# config/routes.rb
Rails.application.routes.draw do
  match "/404", to: "errors#not_found", via: :all
  match "/422", to: "errors#unprocessable", via: :all
  match "/500", to: "errors#internal_error", via: :all
end
# config/application.rb
config.exceptions_app = routes

Error Reporting Integration

Capturing errors in production requires integration with error tracking services. Build an abstraction layer that works with any provider:

# app/services/error_reporter.rb
class ErrorReporter
  class << self
    def capture(exception, context: {}, user: nil)
      return log_locally(exception, context) unless Rails.env.production?

      enriched_context = context.merge(
        user_id: user&.id,
        user_email: user&.email,
        timestamp: Time.current.iso8601
      )

      # Rails 8 built-in error reporting
      Rails.error.report(exception, context: enriched_context, handled: true)

      # Also notify external service
      notify_external_service(exception, enriched_context)
    end

    def capture_message(message, level: :warning, context: {})
      Rails.logger.send(level, "[ErrorReporter] #{message} | #{context.to_json}")

      Rails.error.report(
        StandardError.new(message),
        context: context,
        severity: level
      )
    end

    private

    def log_locally(exception, context)
      Rails.logger.error([
        "Exception: #{exception.class}",
        "Message: #{exception.message}",
        "Context: #{context.to_json}",
        "Backtrace: #{exception.backtrace&.first(5)&.join("\n")}"
      ].join("\n"))
    end

    def notify_external_service(exception, context)
      # Implement webhook, Sentry, or other service integration
    end
  end
end

Use the reporter throughout the application:

# app/controllers/payments_controller.rb
class PaymentsController < ApplicationController
  def create
    result = PaymentProcessor.charge(current_user, amount: params[:amount])

    if result.success?
      redirect_to receipt_path(result.payment)
    else
      ErrorReporter.capture_message(
        "Payment failed",
        level: :warning,
        context: { user_id: current_user.id, amount: params[:amount], reason: result.error }
      )
      raise Errors::PaymentGatewayError.new(result.gateway_response)
    end
  rescue Timeout::Error => e
    ErrorReporter.capture(e, context: { action: "payment_charge" }, user: current_user)
    raise Errors::PaymentGatewayError.new({ code: "timeout" })
  end
end

Testing Error Handling

Verify error handling behaves correctly across formats:

# spec/requests/error_handling_spec.rb
RSpec.describe "Error handling" do
  describe "custom application errors" do
    it "returns forbidden for inaccessible resources" do
      allow_any_instance_of(ProjectsController)
        .to receive(:show)
        .and_raise(Errors::RecordNotAccessible.new("Project", 123))

      get "/projects/123", headers: { "Accept" => "application/json" }

      expect(response).to have_http_status(:forbidden)
      expect(json_response["error"]).to eq("record_not_accessible")
      expect(json_response["code"]).to eq("resource_forbidden")
    end

    it "renders HTML error page for browser requests" do
      allow_any_instance_of(ProjectsController)
        .to receive(:show)
        .and_raise(Errors::RecordNotAccessible.new("Project", 123))

      get "/projects/123"

      expect(response).to have_http_status(:forbidden)
      expect(response.body).to include("Something went wrong")
    end
  end
end

Common Pitfalls

Swallowing exceptions silently: Always log or report errors, even when recovering gracefully. Silent failures create debugging nightmares.

Exposing internal details: Never include stack traces, SQL queries, or internal class names in production error responses. Attackers use this information.

Inconsistent error formats: API consumers expect predictable error structures. Define a schema and stick to it across all endpoints.

Missing Turbo Stream handling: Hotwire applications need error responses in Turbo Stream format, or users see no feedback after failed actions.

Summary

Robust error handling requires multiple layers: custom exception classes that communicate domain concepts, centralized controller rescues for consistent responses, error reporting for production visibility, and user-friendly error pages that maintain trust. Build these foundations early, and the application remains debuggable and professional as complexity grows.

10 claps
← Back to Blog