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
endLoad 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
endUser-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
<%= 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 = routesError 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
endUse 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
endTesting 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
endCommon 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.