CrackedRuby logo

CrackedRuby

Exception Design

This guide covers designing robust exception handling systems in Ruby through custom exception classes, hierarchies, and error management patterns.

Patterns and Best Practices Error Handling
11.4.1

Overview

Ruby's exception system centers on the Exception class hierarchy and provides mechanisms for creating custom error types, managing error propagation, and implementing recovery strategies. Exception design involves creating meaningful error classes that communicate specific failure conditions and provide actionable information for debugging and error handling.

The core exception hierarchy starts with Exception at the top, followed by StandardError for application-level errors, and specialized classes like ArgumentError, TypeError, and RuntimeError. Ruby uses the raise keyword to throw exceptions and rescue clauses to catch and handle them.

class CustomError < StandardError
  attr_reader :error_code, :context
  
  def initialize(message, error_code: nil, context: {})
    super(message)
    @error_code = error_code
    @context = context
  end
end

begin
  raise CustomError.new("Operation failed", error_code: 500, context: {user_id: 123})
rescue CustomError => e
  puts "Error #{e.error_code}: #{e.message}"
  puts "Context: #{e.context}"
end

Exception design patterns include creating domain-specific error hierarchies, implementing error codes and metadata, designing for both programmatic handling and human readability, and establishing consistent error reporting across applications.

Basic Usage

Creating custom exception classes involves inheriting from StandardError or its subclasses. The custom class can include additional attributes and methods to provide context-specific information about the error condition.

class ValidationError < StandardError
  attr_reader :field, :value, :constraint
  
  def initialize(message, field: nil, value: nil, constraint: nil)
    super(message)
    @field = field
    @value = value
    @constraint = constraint
  end
  
  def to_hash
    {
      error: message,
      field: field,
      value: value,
      constraint: constraint
    }
  end
end

# Usage in validation context
def validate_email(email)
  unless email.include?('@')
    raise ValidationError.new(
      "Email format invalid",
      field: :email,
      value: email,
      constraint: "must contain @ symbol"
    )
  end
end

Exception hierarchies organize related errors under common parent classes. This allows rescue clauses to catch groups of related exceptions while still maintaining specific error types for detailed handling.

class DataError < StandardError; end
class ParseError < DataError; end
class FormatError < DataError; end
class CorruptionError < DataError; end

class DocumentProcessor
  def process(document)
    parse_headers(document)
    validate_format(document)
    extract_data(document)
  rescue DataError => e
    log_error(e)
    raise ProcessingFailedError.new("Document processing failed: #{e.message}")
  end
  
  private
  
  def parse_headers(doc)
    raise ParseError.new("Invalid header structure") unless doc.has_headers?
  end
  
  def validate_format(doc)
    raise FormatError.new("Unsupported document format") unless supported?(doc.format)
  end
end

Error messages should provide clear information about what went wrong and how to fix it. Include relevant values, expected conditions, and suggested actions when appropriate.

class ConfigurationError < StandardError
  def self.missing_key(key, config_file)
    new("Missing required configuration key '#{key}' in #{config_file}. " \
        "Add '#{key}: value' to the configuration file.")
  end
  
  def self.invalid_value(key, value, expected)
    new("Invalid value '#{value}' for configuration key '#{key}'. " \
        "Expected #{expected}.")
  end
end

# Usage
def load_config(file)
  config = YAML.load_file(file)
  
  unless config['database_url']
    raise ConfigurationError.missing_key('database_url', file)
  end
  
  unless config['timeout'].is_a?(Integer)
    raise ConfigurationError.invalid_value('timeout', config['timeout'], 'integer')
  end
  
  config
end

Exception handling strategies include catching specific exception types, using ensure blocks for cleanup, and implementing retry logic with exponential backoff.

class RetryableOperation
  MAX_RETRIES = 3
  RETRY_DELAY = 1
  
  def perform_with_retry(operation)
    retries = 0
    
    begin
      operation.call
    rescue TemporaryError => e
      retries += 1
      if retries <= MAX_RETRIES
        sleep(RETRY_DELAY * retries)
        retry
      else
        raise PermanentError.new("Operation failed after #{MAX_RETRIES} retries: #{e.message}")
      end
    ensure
      cleanup_resources
    end
  end
  
  private
  
  def cleanup_resources
    # Resource cleanup logic
  end
end

Advanced Usage

Complex exception hierarchies support sophisticated error handling patterns by organizing exceptions into logical groups and providing inheritance-based polymorphism for error handling code.

module ApiErrors
  class BaseError < StandardError
    attr_reader :status_code, :error_code, :details
    
    def initialize(message, status_code: 500, error_code: nil, details: {})
      super(message)
      @status_code = status_code
      @error_code = error_code || self.class.name.demodulize.underscore
      @details = details
    end
    
    def to_json(*args)
      {
        error: {
          message: message,
          code: error_code,
          status: status_code,
          details: details
        }
      }.to_json(*args)
    end
  end
  
  class ClientError < BaseError
    def initialize(message, status_code: 400, **kwargs)
      super(message, status_code: status_code, **kwargs)
    end
  end
  
  class ServerError < BaseError
    def initialize(message, status_code: 500, **kwargs)
      super(message, status_code: status_code, **kwargs)
    end
  end
  
  class ValidationError < ClientError
    attr_reader :violations
    
    def initialize(message, violations: [], **kwargs)
      super(message, status_code: 422, **kwargs)
      @violations = violations
    end
    
    def add_violation(field, constraint, value = nil)
      @violations << {
        field: field,
        constraint: constraint,
        value: value
      }
    end
  end
  
  class AuthenticationError < ClientError
    def initialize(message = "Authentication required", **kwargs)
      super(message, status_code: 401, **kwargs)
    end
  end
  
  class AuthorizationError < ClientError
    def initialize(message = "Access denied", **kwargs)
      super(message, status_code: 403, **kwargs)
    end
  end
end

Exception factories provide consistent exception creation across an application and can implement complex logic for determining error types and messages based on conditions.

class DatabaseErrorFactory
  ERROR_CODE_MAPPINGS = {
    '23505' => :unique_violation,
    '23503' => :foreign_key_violation,
    '23502' => :not_null_violation,
    '42703' => :undefined_column
  }.freeze
  
  def self.create_from_pg_error(pg_error)
    error_code = pg_error.result&.error_field(PG::Result::PG_DIAG_SQLSTATE)
    error_type = ERROR_CODE_MAPPINGS[error_code]
    
    case error_type
    when :unique_violation
      UniqueConstraintError.new(
        "Duplicate value violates unique constraint",
        constraint: extract_constraint_name(pg_error),
        table: extract_table_name(pg_error)
      )
    when :foreign_key_violation
      ForeignKeyError.new(
        "Foreign key constraint violation",
        constraint: extract_constraint_name(pg_error),
        referenced_table: extract_referenced_table(pg_error)
      )
    when :not_null_violation
      NotNullError.new(
        "Null value in required field",
        column: extract_column_name(pg_error),
        table: extract_table_name(pg_error)
      )
    else
      DatabaseError.new("Database operation failed: #{pg_error.message}")
    end
  end
  
  private
  
  def self.extract_constraint_name(error)
    error.result&.error_field(PG::Result::PG_DIAG_CONSTRAINT_NAME)
  end
  
  def self.extract_table_name(error)
    error.result&.error_field(PG::Result::PG_DIAG_TABLE_NAME)
  end
end

Metaprogramming techniques can create dynamic exception classes and provide DSL-style error definition capabilities.

class ErrorDSL
  def self.define_error_module(module_name, &block)
    error_module = Module.new
    const_set(module_name, error_module)
    
    error_module.extend(ClassMethods)
    error_module.module_eval(&block) if block_given?
    
    error_module
  end
  
  module ClassMethods
    def error_class(name, parent: StandardError, &block)
      error_class = Class.new(parent) do
        attr_reader :metadata
        
        def initialize(message, metadata: {})
          super(message)
          @metadata = metadata
        end
      end
      
      error_class.class_eval(&block) if block_given?
      const_set(name, error_class)
      
      # Create factory method
      method_name = name.to_s.underscore.gsub('_error', '').to_sym
      define_method(method_name) do |message, **kwargs|
        error_class.new(message, metadata: kwargs)
      end
      
      error_class
    end
    
    def error_codes(*codes)
      codes.each do |code|
        error_name = "#{code.to_s.camelize}Error"
        error_class(error_name.to_sym, parent: StandardError) do
          define_method(:code) { code }
        end
      end
    end
  end
end

# Usage
PaymentErrors = ErrorDSL.define_error_module(:PaymentErrors) do
  error_codes :insufficient_funds, :invalid_card, :expired_card, :processing_failed
  
  error_class :RefundError do
    attr_reader :transaction_id, :refund_amount
    
    def initialize(message, transaction_id:, refund_amount:, **metadata)
      super(message, metadata: metadata)
      @transaction_id = transaction_id
      @refund_amount = refund_amount
    end
  end
end

Error Handling & Debugging

Exception debugging requires comprehensive error information including stack traces, context data, and system state at the time of failure. Custom exceptions should capture relevant debugging information without exposing sensitive data.

class DebuggableError < StandardError
  attr_reader :context, :timestamp, :backtrace_locations, :system_info
  
  def initialize(message, context: {})
    super(message)
    @context = context.dup.freeze
    @timestamp = Time.now
    @backtrace_locations = caller_locations
    @system_info = capture_system_info
  end
  
  def debug_info
    {
      error: {
        class: self.class.name,
        message: message,
        timestamp: timestamp.iso8601
      },
      context: context,
      system: system_info,
      backtrace: formatted_backtrace
    }
  end
  
  def formatted_backtrace
    @backtrace_locations&.map do |location|
      {
        file: location.absolute_path,
        line: location.lineno,
        method: location.label
      }
    end
  end
  
  private
  
  def capture_system_info
    {
      ruby_version: RUBY_VERSION,
      platform: RUBY_PLATFORM,
      process_id: Process.pid,
      thread_id: Thread.current.object_id,
      memory_usage: get_memory_usage
    }
  end
  
  def get_memory_usage
    if defined?(GC.stat)
      GC.stat.slice(:heap_allocated_pages, :heap_live_objects, :total_allocated_objects)
    else
      {}
    end
  end
end

Error recovery patterns implement graceful degradation and fallback mechanisms when primary operations fail. Recovery strategies should be tailored to specific error types and business requirements.

class ResilientService
  class ServiceError < StandardError; end
  class TemporaryFailure < ServiceError; end
  class PermanentFailure < ServiceError; end
  
  def initialize(primary_service, fallback_service = nil)
    @primary_service = primary_service
    @fallback_service = fallback_service
    @circuit_breaker = CircuitBreaker.new
  end
  
  def call_with_recovery(operation, *args)
    return call_with_circuit_breaker(operation, *args) if @circuit_breaker.closed?
    
    if @fallback_service && @circuit_breaker.open?
      call_fallback(operation, *args)
    else
      raise ServiceError.new("Service unavailable and no fallback configured")
    end
  rescue TemporaryFailure => e
    if @fallback_service
      log_warning("Primary service failed, using fallback", error: e)
      call_fallback(operation, *args)
    else
      raise ServiceError.new("Temporary failure and no fallback available: #{e.message}")
    end
  rescue PermanentFailure => e
    @circuit_breaker.open!
    raise ServiceError.new("Permanent failure detected: #{e.message}")
  end
  
  private
  
  def call_with_circuit_breaker(operation, *args)
    result = @primary_service.public_send(operation, *args)
    @circuit_breaker.record_success
    result
  rescue StandardError => e
    @circuit_breaker.record_failure
    classify_and_reraise(e)
  end
  
  def call_fallback(operation, *args)
    @fallback_service.public_send(operation, *args)
  rescue StandardError => e
    raise ServiceError.new("Both primary and fallback services failed: #{e.message}")
  end
  
  def classify_and_reraise(error)
    case error
    when Timeout::Error, Errno::ECONNREFUSED
      raise TemporaryFailure.new("Network connectivity issue: #{error.message}")
    when ArgumentError, NoMethodError
      raise PermanentFailure.new("Configuration or interface error: #{error.message}")
    else
      raise TemporaryFailure.new("Unexpected error: #{error.message}")
    end
  end
end

Exception aggregation collects multiple errors from batch operations and presents them as a single, comprehensive error report.

class BatchProcessingError < StandardError
  attr_reader :errors, :successful_items, :failed_items
  
  def initialize(message, errors: [], successful_items: [], failed_items: [])
    super(message)
    @errors = errors.freeze
    @successful_items = successful_items.freeze
    @failed_items = failed_items.freeze
  end
  
  def summary
    {
      total_items: total_items,
      successful_count: successful_items.length,
      failed_count: failed_items.length,
      error_types: error_type_summary
    }
  end
  
  def detailed_report
    report = ["Batch Processing Results:", ""]
    report << "Successfully processed: #{successful_items.length} items"
    report << "Failed to process: #{failed_items.length} items"
    report << ""
    
    if errors.any?
      report << "Error Details:"
      error_groups = errors.group_by(&:class)
      error_groups.each do |error_class, error_list|
        report << "  #{error_class.name}: #{error_list.length} occurrences"
        error_list.first(3).each do |error|
          report << "    - #{error.message}"
        end
        report << "    ... and #{error_list.length - 3} more" if error_list.length > 3
        report << ""
      end
    end
    
    report.join("\n")
  end
  
  private
  
  def total_items
    successful_items.length + failed_items.length
  end
  
  def error_type_summary
    errors.group_by(&:class).transform_values(&:length)
  end
end

class BatchProcessor
  def process_items(items)
    successful_items = []
    failed_items = []
    errors = []
    
    items.each_with_index do |item, index|
      begin
        result = process_single_item(item)
        successful_items << {item: item, result: result, index: index}
      rescue StandardError => e
        failed_items << {item: item, index: index}
        errors << e
      end
    end
    
    if failed_items.any?
      raise BatchProcessingError.new(
        "Batch processing completed with #{failed_items.length} failures",
        errors: errors,
        successful_items: successful_items,
        failed_items: failed_items
      )
    end
    
    successful_items
  end
end

Production Patterns

Production exception handling requires structured logging, error reporting, and monitoring integration. Exceptions should provide sufficient information for debugging while protecting sensitive data.

class ProductionErrorHandler
  include Singleton
  
  attr_accessor :logger, :error_reporter, :notification_service
  
  def initialize
    @logger = Rails.logger if defined?(Rails)
    @error_reporter = nil
    @notification_service = nil
    @sensitive_fields = [:password, :api_key, :token, :secret].freeze
  end
  
  def handle_error(error, context: {})
    error_id = SecureRandom.uuid
    sanitized_context = sanitize_context(context)
    
    log_error(error, error_id, sanitized_context)
    report_error(error, error_id, sanitized_context)
    notify_if_critical(error, error_id)
    
    error_id
  end
  
  def wrap_operation(operation_name, context: {})
    start_time = Time.current
    
    begin
      result = yield
      log_success(operation_name, Time.current - start_time, context)
      result
    rescue StandardError => e
      error_id = handle_error(e, context: context.merge(operation: operation_name))
      
      # Re-raise with error ID for upstream handling
      e.define_singleton_method(:error_id) { error_id }
      raise e
    end
  end
  
  private
  
  def log_error(error, error_id, context)
    return unless @logger
    
    @logger.error({
      error_id: error_id,
      error_class: error.class.name,
      message: error.message,
      backtrace: error.backtrace&.first(10),
      context: context,
      timestamp: Time.current.iso8601
    }.to_json)
  end
  
  def report_error(error, error_id, context)
    return unless @error_reporter
    
    @error_reporter.report(error, {
      error_id: error_id,
      context: context,
      user_id: context[:user_id],
      request_id: context[:request_id]
    })
  end
  
  def notify_if_critical(error, error_id)
    return unless @notification_service
    return unless critical_error?(error)
    
    @notification_service.alert(
      "Critical Error: #{error.class.name}",
      "Error ID: #{error_id}\nMessage: #{error.message}"
    )
  end
  
  def critical_error?(error)
    error.is_a?(SystemExit) ||
    error.is_a?(NoMemoryError) ||
    error.is_a?(SecurityError) ||
    (error.respond_to?(:critical?) && error.critical?)
  end
  
  def sanitize_context(context)
    return context unless context.is_a?(Hash)
    
    context.deep_dup.tap do |sanitized|
      sanitize_hash!(sanitized)
    end
  end
  
  def sanitize_hash!(hash)
    hash.each do |key, value|
      if sensitive_field?(key)
        hash[key] = '[REDACTED]'
      elsif value.is_a?(Hash)
        sanitize_hash!(value)
      elsif value.is_a?(Array)
        value.each { |item| sanitize_hash!(item) if item.is_a?(Hash) }
      end
    end
  end
  
  def sensitive_field?(key)
    key_string = key.to_s.downcase
    @sensitive_fields.any? { |field| key_string.include?(field.to_s) }
  end
  
  def log_success(operation_name, duration, context)
    return unless @logger
    
    @logger.info({
      operation: operation_name,
      status: 'success',
      duration_ms: (duration * 1000).round(2),
      context: context,
      timestamp: Time.current.iso8601
    }.to_json)
  end
end

# Usage in Rails controller
class ApplicationController < ActionController::Base
  around_action :handle_errors_with_context
  
  private
  
  def handle_errors_with_context
    context = {
      controller: controller_name,
      action: action_name,
      user_id: current_user&.id,
      request_id: request.uuid,
      ip_address: request.remote_ip
    }
    
    ProductionErrorHandler.instance.wrap_operation(
      "#{controller_name}##{action_name}",
      context: context
    ) { yield }
  rescue => e
    render json: {
      error: "An error occurred",
      error_id: e.respond_to?(:error_id) ? e.error_id : nil
    }, status: 500
  end
end

Health check and monitoring integration allows operations teams to detect and respond to error patterns quickly.

class ErrorMetrics
  include Singleton
  
  def initialize
    @error_counts = Hash.new(0)
    @error_rates = {}
    @mutex = Mutex.new
    @window_size = 300 # 5 minutes
    @error_history = []
  end
  
  def record_error(error_class, context = {})
    @mutex.synchronize do
      @error_counts[error_class.name] += 1
      @error_history << {
        error_class: error_class.name,
        timestamp: Time.current.to_f,
        context: context
      }
      
      cleanup_old_errors
      calculate_error_rates
    end
  end
  
  def error_summary
    @mutex.synchronize do
      {
        total_errors: @error_history.length,
        error_counts: @error_counts.dup,
        error_rates: @error_rates.dup,
        time_window: @window_size
      }
    end
  end
  
  def health_check
    summary = error_summary
    
    {
      status: determine_health_status(summary),
      error_rate: overall_error_rate,
      top_errors: top_error_classes(5),
      alerts: generate_alerts(summary)
    }
  end
  
  private
  
  def cleanup_old_errors
    cutoff_time = Time.current.to_f - @window_size
    @error_history.reject! { |error| error[:timestamp] < cutoff_time }
    
    # Recalculate counts
    @error_counts.clear
    @error_history.each do |error|
      @error_counts[error[:error_class]] += 1
    end
  end
  
  def calculate_error_rates
    return if @error_history.empty?
    
    time_span = [@window_size, Time.current.to_f - @error_history.first[:timestamp]].min
    
    @error_counts.each do |error_class, count|
      @error_rates[error_class] = count / time_span * 60 # errors per minute
    end
  end
  
  def determine_health_status(summary)
    return :healthy if summary[:total_errors] == 0
    
    error_rate = overall_error_rate
    
    if error_rate > 10
      :critical
    elsif error_rate > 5
      :warning
    else
      :healthy
    end
  end
  
  def overall_error_rate
    return 0 if @error_history.empty?
    
    time_span = [@window_size, Time.current.to_f - @error_history.first[:timestamp]].min
    @error_history.length / time_span * 60
  end
  
  def top_error_classes(limit)
    @error_counts.sort_by { |_, count| -count }.first(limit).to_h
  end
  
  def generate_alerts(summary)
    alerts = []
    
    if overall_error_rate > 10
      alerts << "High error rate: #{overall_error_rate.round(2)} errors/minute"
    end
    
    @error_rates.each do |error_class, rate|
      if rate > 5
        alerts << "High #{error_class} rate: #{rate.round(2)} errors/minute"
      end
    end
    
    alerts
  end
end

Reference

Exception Hierarchy

Class Inherits From Purpose When to Use
Exception BasicObject Root of exception hierarchy Never inherit directly
StandardError Exception Recoverable application errors Base for custom exceptions
RuntimeError StandardError Generic runtime errors Default for raise "message"
ArgumentError StandardError Invalid method arguments Wrong argument count/type
TypeError StandardError Type-related errors Object doesn't respond to method
NameError StandardError Name resolution failures Undefined variable/constant
NoMethodError NameError Method not found Method called on wrong type
SystemExit Exception Process termination Never rescue in application code
Interrupt Exception User interrupt (Ctrl+C) Handle for graceful shutdown

Custom Exception Patterns

Pattern Implementation Use Case
Basic Custom Exception class MyError < StandardError; end Simple error with custom name
Exception with Attributes attr_reader :code, :context in initializer Structured error data
Exception Factory Methods def self.invalid_format(value) Consistent error creation
Exception Hierarchies Multiple levels of inheritance Grouped error handling
Exception with Metadata Hash or OpenStruct for additional data Rich debugging information

Error Handling Methods

Method Signature Returns Description
raise raise(exception_class, message, backtrace) NoReturn Throws exception
rescue rescue ExceptionClass => var Block result Catches specific exceptions
ensure ensure; cleanup; end Block result Always executes cleanup
retry retry Block restart Reruns begin block
catch catch(:symbol) { throw :symbol, value } Thrown value Non-local exit
throw throw(:symbol, value) NoReturn Jumps to catch block

Exception Instance Methods

Method Returns Description
#message String Exception message text
#backtrace Array Stack trace lines
#backtrace_locations ArrayThread::Backtrace::Location Detailed location objects
#cause Exception or nil Wrapped exception
#class Class Exception class
#inspect String Detailed string representation
#to_s String Message or class name

Rescue Clause Modifiers

Pattern Syntax Behavior
Specific Exception rescue ArgumentError Catches only ArgumentError
Multiple Exceptions rescue ArgumentError, TypeError Catches either exception type
Exception Variable rescue StandardError => e Assigns exception to variable
Bare Rescue rescue Catches StandardError and subclasses
Rescue Modifier expression rescue fallback Returns fallback on exception

Exception Design Best Practices

Practice Implementation Benefit
Inherit from StandardError class MyError < StandardError Allows rescue without catching system errors
Provide Meaningful Messages Include context and suggested fixes Improves debugging experience
Add Structured Data Use attributes for error codes and metadata Enables programmatic handling
Create Exception Hierarchies Group related errors under parent classes Allows grouped exception handling
Implement Factory Methods Class methods that create specific error instances Ensures consistent error creation
Include Debugging Information Capture relevant state and context Facilitates production debugging

Error Code Conventions

Code Range Type Usage
1000-1999 Validation Errors Input validation failures
2000-2999 Authentication Errors Login and permission issues
3000-3999 Business Logic Errors Domain rule violations
4000-4999 Integration Errors External service failures
5000-5999 System Errors Infrastructure and resource issues
9000-9999 Unknown Errors Unexpected or unclassified errors

Exception Safety Levels

Level Guarantee Implementation
No Guarantee Operation may corrupt state Avoid in production
Basic Guarantee No resource leaks, valid state Use ensure for cleanup
Strong Guarantee Operation succeeds or state unchanged Transaction-like operations
No-Throw Guarantee Operation never raises exceptions Mark with documentation