Overview
Ruby's exception system centers around a class hierarchy rooted at the Exception
class. The hierarchy provides structured error handling through inheritance, where each exception type represents specific error conditions. The most commonly used base class is StandardError
, which encompasses recoverable application errors.
The hierarchy includes system-level exceptions like SystemExit
and SignalException
that typically should not be rescued in application code, and application-level exceptions descending from StandardError
that represent recoverable errors. Ruby's rescue
clause catches StandardError
and its descendants by default.
begin
raise ArgumentError, "Invalid input"
rescue => e
puts e.class.ancestors
# => [ArgumentError, StandardError, Exception, Object, Kernel, BasicObject]
end
Each exception class can carry a message, backtrace information, and custom data. The inheritance structure allows rescue clauses to catch broad categories of errors or specific error types based on the rescue order and exception class specified.
class DatabaseError < StandardError; end
class ConnectionError < DatabaseError; end
class TimeoutError < DatabaseError; end
begin
raise TimeoutError, "Connection timed out"
rescue ConnectionError => e
puts "Connection issue: #{e.message}"
rescue DatabaseError => e
puts "Database problem: #{e.message}" # This catches TimeoutError
end
The hierarchy supports both built-in exception types like NoMethodError
, TypeError
, and IOError
, and custom application-specific exceptions that inherit from appropriate base classes.
Basic Usage
The exception hierarchy starts with Exception
at the root, but most application code works with StandardError
subclasses. Ruby provides numerous built-in exception classes for common error scenarios.
# Built-in exceptions inherit from StandardError
begin
"".fetch(5) # IndexError
rescue IndexError => e
puts "#{e.class}: #{e.message}"
# => IndexError: string index out of range
end
begin
1 + "string" # TypeError
rescue TypeError => e
puts "#{e.class}: #{e.message}"
# => TypeError: String can't be coerced into Integer
end
Creating custom exceptions follows inheritance patterns where new exception classes extend existing ones. The parent class determines the exception's position in the hierarchy and which rescue clauses will catch it.
class ValidationError < StandardError
attr_reader :field, :value
def initialize(field, value, message = nil)
@field = field
@value = value
super(message || "#{field} has invalid value: #{value}")
end
end
class EmailValidationError < ValidationError; end
class PasswordValidationError < ValidationError; end
def validate_user(email, password)
raise EmailValidationError.new(:email, email) unless email.include?("@")
raise PasswordValidationError.new(:password, password) if password.length < 8
end
begin
validate_user("invalid-email", "short")
rescue EmailValidationError => e
puts "Email error: #{e.message} (field: #{e.field})"
rescue ValidationError => e
puts "General validation error: #{e.message}"
end
The hierarchy determines rescue precedence. More specific exceptions must be rescued before their parent classes, or the parent rescue clause will catch the more specific exception.
begin
raise NoMethodError, "undefined method"
rescue StandardError => e
puts "Caught as StandardError: #{e.class}"
# => Caught as StandardError: NoMethodError
rescue NoMethodError => e
puts "This never executes"
end
Multiple exception types can be rescued together by specifying them in a single rescue clause, which catches any exception that matches the hierarchy of the specified classes.
begin
# Some operation that might fail
case rand(3)
when 0 then raise ArgumentError
when 1 then raise TypeError
when 2 then raise NoMethodError
end
rescue ArgumentError, TypeError => e
puts "Argument or type error: #{e.class}"
rescue => e
puts "Other standard error: #{e.class}"
end
Advanced Usage
Custom exception hierarchies allow applications to model domain-specific error conditions while maintaining proper inheritance relationships. Complex applications often define exception trees that mirror their functional architecture.
module PaymentProcessing
class PaymentError < StandardError
attr_reader :transaction_id, :amount, :error_code
def initialize(transaction_id, amount, message, error_code = nil)
@transaction_id = transaction_id
@amount = amount
@error_code = error_code
super(message)
end
end
class InsufficientFundsError < PaymentError; end
class InvalidCardError < PaymentError; end
class NetworkError < PaymentError
attr_reader :retry_after
def initialize(transaction_id, amount, message, retry_after = 30)
@retry_after = retry_after
super(transaction_id, amount, message, 'NETWORK_ERROR')
end
end
class GatewayError < PaymentError
class TimeoutError < GatewayError; end
class AuthenticationError < GatewayError; end
class ServiceUnavailableError < GatewayError; end
end
end
def process_payment(transaction_id, amount, card)
case card.status
when 'expired'
raise PaymentProcessing::InvalidCardError.new(
transaction_id, amount, "Card expired", 'EXPIRED_CARD'
)
when 'insufficient_funds'
raise PaymentProcessing::InsufficientFundsError.new(
transaction_id, amount, "Insufficient funds"
)
end
# Simulate network timeout
if rand > 0.7
raise PaymentProcessing::NetworkError.new(
transaction_id, amount, "Network timeout occurred", 60
)
end
end
Exception class hierarchies can include class methods and modules to provide specialized behavior for error handling, logging, and recovery operations.
module Retriable
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def max_retries(count = nil)
if count
@max_retries = count
else
@max_retries || 3
end
end
end
def retriable?
self.class.max_retries > 0
end
end
class TransientError < StandardError
include Retriable
max_retries 5
end
class DatabaseConnectionError < TransientError
max_retries 10
end
class PermanentError < StandardError
include Retriable
max_retries 0
end
def handle_with_retry(exception, attempt = 1)
if exception.retriable? && attempt <= exception.class.max_retries
puts "Retrying operation (attempt #{attempt})"
sleep(2 ** attempt) # Exponential backoff
yield
else
raise exception
end
end
Metaprogramming techniques can dynamically create exception classes based on configuration or runtime conditions, maintaining proper hierarchy relationships.
class ApiClient
def self.define_error_classes(api_config)
api_config.each do |service, errors|
service_error_class = Class.new(StandardError) do
define_method :initialize do |message, error_code = nil|
@error_code = error_code
super(message)
end
attr_reader :error_code
end
const_set("#{service.capitalize}Error", service_error_class)
errors.each do |error_type|
error_class = Class.new(service_error_class)
service_error_class.const_set("#{error_type.capitalize}Error", error_class)
end
end
end
end
api_config = {
user: ['authentication', 'authorization', 'validation'],
payment: ['insufficient_funds', 'invalid_card', 'network'],
inventory: ['out_of_stock', 'reserved', 'discontinued']
}
ApiClient.define_error_classes(api_config)
# Now you can use: ApiClient::UserError::AuthenticationError
# Or: ApiClient::PaymentError::NetworkError
Error Handling & Debugging
Exception hierarchies provide structured approaches to error handling that can differentiate between recoverable and non-recoverable errors, implement retry logic, and maintain error context across application boundaries.
class ApplicationService
class ServiceError < StandardError
attr_reader :context, :severity, :recoverable
def initialize(message, context: {}, severity: :error, recoverable: true)
@context = context
@severity = severity
@recoverable = recoverable
super(message)
end
def loggable_data
{
error_class: self.class.name,
message: message,
severity: severity,
recoverable: recoverable,
context: context,
backtrace: backtrace&.first(10)
}
end
end
class ValidationError < ServiceError
def initialize(field, value, message = nil)
super(
message || "Invalid #{field}: #{value}",
context: { field: field, value: value },
severity: :warning,
recoverable: true
)
end
end
class ExternalServiceError < ServiceError
def initialize(service, response_code, message)
super(
"#{service} error: #{message}",
context: { service: service, response_code: response_code },
severity: response_code >= 500 ? :critical : :error,
recoverable: response_code < 500
)
end
end
class DataIntegrityError < ServiceError
def initialize(message, affected_records: [])
super(
message,
context: { affected_records: affected_records },
severity: :critical,
recoverable: false
)
end
end
end
def process_with_error_handling
begin
yield
rescue ApplicationService::ValidationError => e
logger.warn(e.loggable_data)
{ success: false, error: e.message, type: 'validation' }
rescue ApplicationService::ExternalServiceError => e
if e.recoverable
logger.error(e.loggable_data)
retry_operation(e)
else
logger.critical(e.loggable_data)
{ success: false, error: 'Service unavailable', type: 'external' }
end
rescue ApplicationService::DataIntegrityError => e
logger.critical(e.loggable_data)
initiate_data_recovery(e.context[:affected_records])
{ success: false, error: 'System error occurred', type: 'critical' }
end
end
Debugging exception hierarchies requires understanding the inheritance chain and how rescue clauses interact with the hierarchy. The Exception#cause
mechanism links related exceptions.
class ChainedExceptionHandler
def self.handle_with_context(original_error, context_message)
begin
yield
rescue => e
# Create a new exception that wraps the original
wrapper = StandardError.new("#{context_message}: #{e.message}")
wrapper.set_backtrace(e.backtrace)
# Ruby 2.1+ supports cause chaining
if wrapper.respond_to?(:cause=)
wrapper.cause = e
end
raise wrapper
end
end
def self.print_exception_chain(exception)
current = exception
level = 0
while current
puts "#{' ' * level}#{current.class}: #{current.message}"
puts "#{' ' * level} #{current.backtrace&.first}" if current.backtrace
current = current.respond_to?(:cause) ? current.cause : nil
level += 2
end
end
end
begin
ChainedExceptionHandler.handle_with_context("Database operation failed") do
ChainedExceptionHandler.handle_with_context("User validation failed") do
raise ArgumentError, "Email format invalid"
end
end
rescue => e
ChainedExceptionHandler.print_exception_chain(e)
end
Exception hierarchies support sophisticated error recovery patterns where different exception types trigger different recovery strategies.
module ErrorRecovery
class RecoveryStrategy
def self.for_exception(exception)
case exception
when NetworkTimeoutError then RetryWithBackoffStrategy.new
when AuthenticationError then ReauthenticateStrategy.new
when ValidationError then UserInputStrategy.new
when DataCorruptionError then RestoreFromBackupStrategy.new
else DefaultStrategy.new
end
end
end
def execute_with_recovery
attempt = 1
max_attempts = 3
begin
yield
rescue StandardError => e
strategy = RecoveryStrategy.for_exception(e)
if attempt <= max_attempts && strategy.can_retry?
strategy.prepare_retry(attempt)
attempt += 1
retry
else
strategy.handle_failure(e)
raise
end
end
end
end
Common Pitfalls
Exception hierarchy design often suffers from overly broad rescue clauses that catch more exceptions than intended. The hierarchy's inheritance nature means parent class rescues will catch all descendant exceptions.
# Problematic: catches too much
begin
process_user_data
rescue StandardError => e # Catches ALL standard errors
log_error(e)
return default_response
end
# Better: specific exception handling
begin
process_user_data
rescue ValidationError, TypeError => e
log_validation_error(e)
return validation_error_response(e)
rescue NetworkError, IOError => e
log_network_error(e)
return network_error_response(e)
rescue => e
log_unexpected_error(e)
raise # Re-raise unexpected errors
end
Order dependency in rescue clauses creates subtle bugs where more specific exceptions never get caught because broader parent classes are rescued first.
# Wrong order - specific exceptions never caught
begin
raise NoMethodError, "method missing"
rescue StandardError => e
puts "Caught as StandardError"
rescue NoMethodError => e
puts "This never executes" # Dead code
rescue NameError => e
puts "This never executes" # Dead code
end
# Correct order - specific to general
begin
risky_operation
rescue NoMethodError => e
handle_missing_method(e)
rescue NameError => e
handle_name_error(e)
rescue StandardError => e
handle_general_error(e)
end
Custom exceptions that don't inherit from StandardError
create rescue clause problems because bare rescue
clauses don't catch them.
class CustomError < Exception # Wrong: inherits from Exception
end
begin
raise CustomError, "something went wrong"
rescue => e # Won't catch CustomError
puts "This doesn't execute"
end
# Correct inheritance
class CustomError < StandardError # Inherits from StandardError
end
begin
raise CustomError, "something went wrong"
rescue => e # Now catches CustomError
puts "Properly caught: #{e.class}"
end
Exception class initialization can become complex when passing multiple parameters, but forgetting to call super
breaks message handling and backtrace functionality.
class BadCustomError < StandardError
attr_reader :code, :details
def initialize(code, details)
@code = code
@details = details
# Missing super() call - breaks message and backtrace
end
end
class GoodCustomError < StandardError
attr_reader :code, :details
def initialize(message, code: nil, details: {})
@code = code
@details = details
super(message) # Properly initializes Exception behavior
end
end
begin
raise BadCustomError.new(500, {user_id: 123})
rescue => e
puts e.message # => "BadCustomError" (default, not helpful)
puts e.backtrace.nil? # May be true
end
begin
raise GoodCustomError.new("Service unavailable", code: 500, details: {user_id: 123})
rescue => e
puts e.message # => "Service unavailable"
puts e.code # => 500
puts e.backtrace.first # Proper backtrace
end
Rescue modifier syntax with exception hierarchies can mask important errors because it catches StandardError
by default, potentially hiding bugs.
# Dangerous: hides all StandardError exceptions
result = risky_operation rescue nil
# May hide important errors like:
# - NoMethodError (typos in method names)
# - ArgumentError (wrong parameters)
# - TypeError (wrong data types)
# Better: explicit exception handling
result = begin
risky_operation
rescue NetworkError, TimeoutError
nil # Only catch expected, recoverable errors
end
Thread safety issues emerge when exception objects carry mutable state that gets modified during exception handling, especially in concurrent environments.
class ThreadUnsafeError < StandardError
attr_accessor :retry_count
def initialize(message)
super(message)
@retry_count = 0
end
end
# Problematic in concurrent code
shared_exception = ThreadUnsafeError.new("shared error")
Thread.new do
begin
raise shared_exception
rescue ThreadUnsafeError => e
e.retry_count += 1 # Race condition
end
end
# Better: immutable exception state or thread-local modifications
class ThreadSafeError < StandardError
attr_reader :retry_count
def initialize(message, retry_count: 0)
super(message)
@retry_count = retry_count.freeze
end
def with_incremented_retry
self.class.new(message, retry_count: retry_count + 1)
end
end
Production Patterns
Production exception handling requires balancing information disclosure, performance, monitoring, and user experience. Exception hierarchies support these requirements through structured error classification and handling.
class ProductionErrorHandler
SENSITIVE_ERRORS = [
SecurityError,
AuthenticationError,
AuthorizationError
].freeze
CLIENT_SAFE_ERRORS = [
ValidationError,
NotFoundError,
BadRequestError
].freeze
RETRIABLE_ERRORS = [
NetworkTimeoutError,
DatabaseConnectionError,
ExternalServiceError
].freeze
def self.handle_request_error(error, request_context)
error_data = build_error_data(error, request_context)
# Log with appropriate level
log_error(error, error_data)
# Notify monitoring systems
notify_error_tracking(error, error_data) if should_notify?(error)
# Return client-appropriate response
build_client_response(error, request_context)
end
private
def self.build_error_data(error, context)
{
error_class: error.class.name,
message: error.message,
backtrace: filter_backtrace(error.backtrace),
request_id: context[:request_id],
user_id: context[:user_id],
endpoint: context[:endpoint],
parameters: filter_sensitive_params(context[:params]),
timestamp: Time.current.iso8601,
environment: Rails.env,
server_id: ENV['SERVER_ID']
}
end
def self.log_error(error, data)
level = case error
when *SENSITIVE_ERRORS then :warn
when SecurityError then :error
when SystemStackError, NoMemoryError then :fatal
else :error
end
Rails.logger.send(level, data.to_json)
end
def self.build_client_response(error, context)
case error
when *CLIENT_SAFE_ERRORS
{
error: {
type: error.class.name.underscore,
message: error.message,
details: error.respond_to?(:details) ? error.details : {}
}
}
when *SENSITIVE_ERRORS
{
error: {
type: 'authentication_error',
message: 'Authentication required'
}
}
else
{
error: {
type: 'internal_error',
message: 'An internal error occurred',
request_id: context[:request_id]
}
}
end
end
end
# Rails controller integration
class ApplicationController < ActionController::Base
rescue_from StandardError do |error|
request_context = {
request_id: request.uuid,
user_id: current_user&.id,
endpoint: "#{controller_name}##{action_name}",
params: params.except(:password, :token, :secret)
}
response_data = ProductionErrorHandler.handle_request_error(error, request_context)
status_code = case error
when ValidationError then 422
when NotFoundError then 404
when AuthenticationError then 401
when AuthorizationError then 403
else 500
end
render json: response_data, status: status_code
end
end
Circuit breaker patterns use exception hierarchies to determine when services should be protected from cascading failures.
class CircuitBreaker
STATES = %i[closed open half_open].freeze
def initialize(failure_threshold: 5, recovery_timeout: 60)
@failure_threshold = failure_threshold
@recovery_timeout = recovery_timeout
@failure_count = 0
@last_failure_time = nil
@state = :closed
end
def call
case @state
when :closed
execute_with_circuit_protection { yield }
when :open
check_if_can_attempt_recovery
raise CircuitOpenError, "Circuit breaker is open"
when :half_open
execute_recovery_attempt { yield }
end
end
private
def execute_with_circuit_protection
begin
result = yield
handle_success
result
rescue CircuitBreakerError => e
handle_circuit_breaker_failure(e)
raise
rescue StandardError => e
handle_general_failure(e)
raise
end
end
def handle_circuit_breaker_failure(error)
@failure_count += 1
@last_failure_time = Time.current
if @failure_count >= @failure_threshold
@state = :open
Rails.logger.warn("Circuit breaker opened due to #{error.class}")
end
end
def handle_general_failure(error)
# Only certain errors should count toward circuit breaker
if error.is_a?(NetworkTimeoutError) ||
error.is_a?(ExternalServiceError) ||
(error.respond_to?(:response_code) && error.response_code >= 500)
handle_circuit_breaker_failure(error)
end
end
end
class CircuitBreakerError < StandardError; end
class CircuitOpenError < CircuitBreakerError; end
Background job error handling leverages exception hierarchies to determine retry strategies, failure notifications, and job routing.
class JobErrorHandler
RETRIABLE_ERRORS = [
NetworkTimeoutError,
DatabaseConnectionError,
RateLimitError
].freeze
PERMANENT_FAILURES = [
ValidationError,
SecurityError,
NotFoundError
].freeze
def self.handle_job_error(job, error, attempt)
job_context = {
job_class: job.class.name,
job_id: job.job_id,
queue: job.queue_name,
attempt: attempt,
args: job.arguments
}
case error
when *RETRIABLE_ERRORS
handle_retriable_error(job, error, job_context)
when *PERMANENT_FAILURES
handle_permanent_failure(job, error, job_context)
else
handle_unknown_error(job, error, job_context)
end
end
private
def self.handle_retriable_error(job, error, context)
if context[:attempt] < max_retries_for_error(error)
delay = calculate_retry_delay(error, context[:attempt])
job.retry_job(wait: delay)
log_retry(error, context, delay)
else
handle_permanent_failure(job, error, context)
end
end
def self.max_retries_for_error(error)
case error
when NetworkTimeoutError then 5
when DatabaseConnectionError then 10
when RateLimitError then 3
else 2
end
end
end
class ApplicationJob < ActiveJob::Base
rescue_from StandardError do |error|
JobErrorHandler.handle_job_error(self, error, executions)
end
end
Reference
Core Exception Classes
Class | Parent | Purpose | Default Rescue |
---|---|---|---|
Exception |
Object |
Root of exception hierarchy | No |
StandardError |
Exception |
Application errors | Yes |
RuntimeError |
StandardError |
Generic runtime errors | Yes |
NoMethodError |
NameError |
Missing method calls | Yes |
ArgumentError |
StandardError |
Wrong method arguments | Yes |
TypeError |
StandardError |
Type mismatch errors | Yes |
SystemExit |
Exception |
Program termination | No |
SignalException |
Exception |
System signals | No |
SystemStackError |
Exception |
Stack overflow | No |
StandardError Subclasses
Class | Purpose | Common Causes |
---|---|---|
IOError |
Input/output errors | File operations, network I/O |
EOFError |
End of file reached | Reading past file end |
Errno::* |
System call errors | File permissions, disk space |
ThreadError |
Thread-related errors | Invalid thread operations |
FiberError |
Fiber-related errors | Invalid fiber state |
ZeroDivisionError |
Division by zero | Mathematical operations |
FloatDomainError |
Invalid float operations | Math domain errors |
RangeError |
Value out of range | Numeric conversions |
RegexpError |
Regular expression errors | Invalid regex patterns |
EncodingError |
String encoding issues | Character encoding problems |
Exception Methods
Method | Returns | Description |
---|---|---|
#message |
String |
Exception message text |
#backtrace |
Array<String> |
Stack trace lines |
#backtrace_locations |
Array<Thread::Backtrace::Location> |
Detailed stack trace |
#cause |
Exception or nil |
Wrapped exception (Ruby 2.1+) |
#full_message |
String |
Formatted exception with backtrace |
#inspect |
String |
Class name and message |
#set_backtrace(array) |
Array |
Set custom backtrace |
.exception(message) |
Exception |
Create exception instance |
Rescue Clause Syntax
Syntax | Catches | Example |
---|---|---|
rescue |
StandardError and subclasses |
rescue => e |
rescue ExceptionClass |
Specified class and subclasses | rescue ArgumentError |
rescue Class1, Class2 |
Multiple specific classes | rescue IOError, SystemCallError |
rescue Class1 => var |
Assign caught exception | rescue TypeError => error |
rescue *array |
Dynamic exception array | rescue *[IOError, SystemCallError] |
Exception Raising
Method | Purpose | Example |
---|---|---|
raise |
Re-raise current exception | rescue => e; log(e); raise |
raise "message" |
Raise RuntimeError | raise "Something went wrong" |
raise Class |
Raise exception class | raise ArgumentError |
raise Class, "message" |
Raise with message | raise TypeError, "Wrong type" |
raise exception_instance |
Raise instance | raise CustomError.new(data) |
Custom Exception Patterns
# Basic custom exception
class CustomError < StandardError; end
# Exception with additional data
class DataError < StandardError
attr_reader :data
def initialize(message, data = {})
@data = data
super(message)
end
end
# Exception hierarchy
class ServiceError < StandardError; end
class NetworkError < ServiceError; end
class TimeoutError < NetworkError; end
# Exception with class-level configuration
class ConfigurableError < StandardError
class << self
attr_accessor :default_message, :severity
end
def initialize(message = self.class.default_message)
super(message)
end
end
# Exception wrapping pattern
class WrappedError < StandardError
attr_reader :original_error
def initialize(message, original_error)
@original_error = original_error
super("#{message}: #{original_error.message}")
set_backtrace(original_error.backtrace)
end
end
Hierarchy Navigation
# Check exception ancestry
exception.is_a?(StandardError) # => true/false
exception.class.ancestors # => [Class, Parent, ...]
# Find common ancestor
def common_ancestor(exc1, exc2)
(exc1.class.ancestors & exc2.class.ancestors).first
end
# Build exception tree
def exception_tree(root_class = Exception)
ObjectSpace.each_object(Class).select { |c| c < root_class }
.group_by(&:superclass)
end