CrackedRuby logo

CrackedRuby

Exception Classes Hierarchy

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