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.