CrackedRuby CrackedRuby

Error Handling Strategies

Overview

Error handling strategies define how applications detect, report, and recover from exceptional conditions that occur during program execution. These strategies determine the flow of control when operations fail, how error information propagates through the system, and what mechanisms exist for recovery or graceful degradation.

The choice of error handling strategy affects system reliability, maintainability, debugging efficiency, and user experience. Different strategies suit different contexts: some prioritize immediate failure detection, others emphasize recovery and continuation, while some optimize for clean separation between normal and error-handling code.

Ruby provides multiple error handling mechanisms, each with distinct characteristics. The exception system offers structured error propagation through the call stack. Return value checking provides explicit error handling at call sites. Error codes and status objects enable more granular error representation. The language also supports defensive programming through validation, contracts, and fail-fast principles.

# Exception-based approach
def process_payment(amount)
  raise ArgumentError, "Amount must be positive" if amount <= 0
  # Process payment
rescue PaymentGatewayError => e
  Logger.error("Payment failed: #{e.message}")
  raise
end

# Return value approach
def validate_email(email)
  return false unless email.include?('@')
  return false unless email.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
  true
end

The distinction between recoverable and non-recoverable errors influences strategy selection. Recoverable errors represent expected failure modes where alternative actions exist. Non-recoverable errors indicate programming defects or violated invariants that require immediate termination or debugging attention.

Key Principles

Fail-Fast Principle: Detect and report errors as close to their source as possible. When invalid state or conditions arise, immediate failure prevents error propagation and simplifies debugging. Delayed failure detection obscures root causes as errors manifest far from their origin.

Error Context Preservation: Maintain sufficient information about error conditions to diagnose failures. Context includes the operation attempted, parameters involved, system state, and causal chain. Without adequate context, debugging becomes archaeological work rather than systematic investigation.

Separation of Concerns: Distinguish between error detection, error handling, and error recovery. Detection identifies exceptional conditions. Handling determines the response. Recovery attempts to restore valid state. Mixing these concerns creates tangled code where error logic obscures primary functionality.

Exception Safety Guarantees: Define what invariants hold after exceptions occur. The basic guarantee ensures no resource leaks but allows modified state. The strong guarantee provides transactional semantics where operations either complete fully or leave state unchanged. The no-throw guarantee promises operations never raise exceptions.

Propagation vs Local Handling: Errors can propagate up the call stack for centralized handling or receive handling at the site where they occur. Propagation enables higher-level code to make decisions based on broader context. Local handling maintains encapsulation and reduces coupling between layers.

class BankAccount
  def withdraw(amount)
    # Fail-fast validation
    raise ArgumentError, "Amount must be positive" if amount <= 0
    raise InsufficientFundsError, "Balance: #{@balance}, requested: #{amount}" if amount > @balance
    
    # Strong exception safety - operation completes or state unchanged
    original_balance = @balance
    begin
      @balance -= amount
      record_transaction(:withdrawal, amount)
    rescue TransactionError => e
      @balance = original_balance  # Restore state
      raise
    end
  end
end

Error Classification: Distinguish between different error categories based on their nature and appropriate response. Precondition violations indicate caller errors. Postcondition violations suggest implementation defects. External failures represent uncontrollable environmental issues. Resource exhaustion signals capacity problems. Each category warrants different handling approaches.

Graduated Response: Match response severity to error significance. Critical errors require immediate termination. Warnings indicate degraded functionality but allow continuation. Informational messages document anomalies without altering behavior. Overreacting to minor issues creates noise; underreacting to serious problems allows damage accumulation.

Ruby Implementation

Ruby's exception hierarchy provides the foundation for structured error handling. The Exception class sits at the top, with StandardError as the parent for application-level exceptions. Ruby's rescue clause catches StandardError and its descendants by default, while system-level exceptions like NoMemoryError and SignalException require explicit handling.

begin
  risky_operation
rescue ArgumentError => e
  # Handle invalid arguments
  puts "Invalid argument: #{e.message}"
rescue IOError => e
  # Handle I/O failures
  puts "I/O operation failed: #{e.message}"
rescue StandardError => e
  # Catch-all for other standard errors
  puts "Unexpected error: #{e.message}"
ensure
  # Always executes, even after return or exception
  cleanup_resources
end

Custom exception classes enable precise error categorization and specialized handling. Exceptions carry context through message strings and custom attributes. Inheritance hierarchies support both specific and general rescue clauses.

class ApplicationError < StandardError; end
class ValidationError < ApplicationError
  attr_reader :field, :value
  
  def initialize(message, field: nil, value: nil)
    super(message)
    @field = field
    @value = value
  end
end

class PersistenceError < ApplicationError
  attr_reader :operation, :record_id
  
  def initialize(message, operation:, record_id: nil)
    super(message)
    @operation = operation
    @record_id = record_id
  end
end

# Usage with context
def create_user(email)
  raise ValidationError.new("Invalid email format", field: :email, value: email) unless valid_email?(email)
  # Create user
rescue ActiveRecord::RecordNotUnique
  raise PersistenceError.new("Email already exists", operation: :create)
end

The raise method initiates exception propagation. It accepts an exception class, message string, or exception instance. The retry keyword restarts the begin block, useful for transient failures. The throw/catch mechanism provides non-local exits without exception overhead.

def fetch_with_retry(url, max_attempts: 3)
  attempts = 0
  begin
    attempts += 1
    HTTP.get(url)
  rescue Timeout::Error, ConnectionError => e
    if attempts < max_attempts
      sleep(2 ** attempts)  # Exponential backoff
      retry
    else
      raise FetchError, "Failed after #{attempts} attempts: #{e.message}"
    end
  end
end

Ruby's rescue modifier enables inline error handling for simple cases, though it catches all StandardError descendants indiscriminately. The raise without arguments re-raises the current exception, preserving the original backtrace.

# Inline rescue for simple fallback
result = parse_json(data) rescue {}

# Re-raising preserves backtrace
def process_data(data)
  validate(data)
rescue ValidationError => e
  log_validation_failure(e)
  raise  # Re-raise original exception
end

Method definitions support rescue clauses directly without explicit begin blocks. This creates cleaner code when entire methods need exception handling.

def load_configuration(path)
  YAML.load_file(path)
rescue Errno::ENOENT
  default_configuration
rescue Psych::SyntaxError => e
  raise ConfigurationError, "Invalid YAML in #{path}: #{e.message}"
end

The ensure clause guarantees execution regardless of normal completion, exception, or early return. This makes it ideal for resource cleanup, though Ruby's block-based resource management often provides better alternatives.

def process_file(path)
  file = File.open(path)
  process(file.read)
ensure
  file&.close
end

# Block-based alternative (preferred)
def process_file(path)
  File.open(path) do |file|
    process(file.read)
  end  # Automatic cleanup
end

Common Patterns

Circuit Breaker: Prevents repeated attempts to invoke failing operations. After a threshold of failures, the circuit "opens" and immediately rejects requests for a timeout period. Periodic checks determine when to attempt recovery by "closing" the circuit.

class CircuitBreaker
  STATES = [:closed, :open, :half_open].freeze
  
  def initialize(failure_threshold: 5, timeout: 60)
    @failure_threshold = failure_threshold
    @timeout = timeout
    @failure_count = 0
    @state = :closed
    @opened_at = nil
  end
  
  def call
    case @state
    when :open
      if Time.now - @opened_at > @timeout
        @state = :half_open
        attempt_call
      else
        raise CircuitOpenError, "Circuit breaker is open"
      end
    when :half_open, :closed
      attempt_call
    end
  end
  
  private
  
  def attempt_call
    result = yield
    reset_failures
    result
  rescue StandardError => e
    record_failure
    raise
  end
  
  def record_failure
    @failure_count += 1
    if @failure_count >= @failure_threshold
      @state = :open
      @opened_at = Time.now
    end
  end
  
  def reset_failures
    @failure_count = 0
    @state = :closed
  end
end

# Usage
payment_breaker = CircuitBreaker.new(failure_threshold: 3, timeout: 30)
payment_breaker.call { process_payment(amount) }

Null Object Pattern: Replaces nil checks with polymorphic objects that respond to the same interface but perform benign operations. This eliminates conditional logic and prevents NoMethodError exceptions.

class User
  attr_reader :name, :email
  
  def initialize(name, email)
    @name = name
    @email = email
  end
  
  def permissions
    ['read', 'write']
  end
end

class GuestUser
  def name
    'Guest'
  end
  
  def email
    nil
  end
  
  def permissions
    ['read']
  end
end

# Usage without nil checks
def display_user_info(user)
  puts "Name: #{user.name}"
  puts "Permissions: #{user.permissions.join(', ')}"
  # No need for: if user && user.name
end

current_user = authenticated? ? User.new('Alice', 'alice@example.com') : GuestUser.new
display_user_info(current_user)

Result Object Pattern: Encapsulates operation outcomes including success/failure status and associated data or errors. This explicit approach avoids exceptions for expected failures while maintaining type safety.

class Result
  attr_reader :value, :error
  
  def initialize(success, value: nil, error: nil)
    @success = success
    @value = value
    @error = error
  end
  
  def success?
    @success
  end
  
  def failure?
    !@success
  end
  
  def self.success(value)
    new(true, value: value)
  end
  
  def self.failure(error)
    new(false, error: error)
  end
end

def find_user(id)
  user = User.find_by(id: id)
  if user
    Result.success(user)
  else
    Result.failure("User not found: #{id}")
  end
end

# Usage
result = find_user(123)
if result.success?
  process_user(result.value)
else
  log_error(result.error)
end

Retry with Backoff: Automatically retries failed operations with increasing delays between attempts. Exponential backoff prevents overwhelming already-stressed systems while allowing recovery from transient failures.

def with_retry(max_attempts: 3, base_delay: 1, max_delay: 32)
  attempts = 0
  begin
    attempts += 1
    yield
  rescue => e
    if attempts < max_attempts
      delay = [base_delay * (2 ** (attempts - 1)), max_delay].min
      sleep(delay + rand * delay * 0.1)  # Add jitter
      retry
    else
      raise
    end
  end
end

# Usage
with_retry(max_attempts: 5, base_delay: 2) do
  call_external_api
end

Error Accumulation: Collects multiple errors instead of failing on the first one. Validation scenarios benefit from showing all problems at once rather than forcing users through iterative fix-and-retry cycles.

class ValidationErrors
  def initialize
    @errors = Hash.new { |h, k| h[k] = [] }
  end
  
  def add(field, message)
    @errors[field] << message
  end
  
  def any?
    @errors.any?
  end
  
  def each(&block)
    @errors.each(&block)
  end
  
  def to_h
    @errors
  end
end

def validate_user(params)
  errors = ValidationErrors.new
  
  errors.add(:name, "Name is required") if params[:name].to_s.empty?
  errors.add(:name, "Name too long") if params[:name].to_s.length > 100
  errors.add(:email, "Email is required") if params[:email].to_s.empty?
  errors.add(:email, "Invalid email format") unless valid_email?(params[:email])
  errors.add(:age, "Age must be positive") if params[:age].to_i <= 0
  
  raise ValidationError.new(errors) if errors.any?
end

Practical Examples

Database Transaction Rollback: Operations that modify multiple records require consistent error handling to maintain data integrity. Exceptions trigger automatic rollback, while successful completion commits changes.

class AccountTransfer
  def execute(from_account, to_account, amount)
    Account.transaction do
      # Validations fail fast
      raise InsufficientFundsError if from_account.balance < amount
      raise AccountFrozenError if from_account.frozen? || to_account.frozen?
      
      # Both operations succeed or both roll back
      from_account.withdraw(amount)
      to_account.deposit(amount)
      
      # Record transfer
      Transfer.create!(
        from_account_id: from_account.id,
        to_account_id: to_account.id,
        amount: amount,
        status: 'completed'
      )
    end
  rescue ActiveRecord::RecordInvalid => e
    # Transaction already rolled back
    log_error("Transfer validation failed", error: e, from: from_account.id, to: to_account.id)
    raise TransferError, "Invalid transfer: #{e.message}"
  rescue InsufficientFundsError, AccountFrozenError => e
    # Domain-specific error handling
    log_error("Transfer rejected", error: e, from: from_account.id, to: to_account.id)
    raise
  rescue StandardError => e
    # Unexpected errors
    log_error("Transfer system error", error: e, from: from_account.id, to: to_account.id)
    raise TransferError, "System error during transfer"
  end
end

File Processing Pipeline: Multi-stage processing requires error handling that distinguishes between recoverable and fatal errors while ensuring resource cleanup.

class FileProcessor
  def process_batch(file_paths)
    results = { success: [], failed: [], skipped: [] }
    
    file_paths.each do |path|
      begin
        process_single_file(path, results)
      rescue FileNotFoundError => e
        results[:skipped] << { path: path, reason: "File not found" }
        log_warning("Skipping missing file", path: path)
      rescue CorruptedFileError => e
        results[:failed] << { path: path, error: e.message }
        log_error("Corrupted file", path: path, error: e)
        # Continue processing other files
      rescue StandardError => e
        results[:failed] << { path: path, error: e.message }
        log_error("Unexpected error processing file", path: path, error: e)
        # Decide whether to continue or abort batch
        raise if critical_error?(e)
      end
    end
    
    results
  end
  
  private
  
  def process_single_file(path, results)
    File.open(path, 'r') do |file|
      validate_format(file)
      data = parse_content(file)
      transformed = transform_data(data)
      store_results(transformed)
      results[:success] << { path: path, records: data.size }
    end
  end
  
  def critical_error?(error)
    error.is_a?(DatabaseConnectionError) || error.is_a?(DiskFullError)
  end
end

API Request with Fallback: External service calls require handling network failures, timeouts, and degraded service while providing fallback options.

class WeatherService
  def fetch_forecast(location)
    primary_result = fetch_from_primary(location)
    cache_result(location, primary_result)
    primary_result
  rescue Timeout::Error, ConnectionError => e
    log_warning("Primary service unavailable", location: location, error: e)
    fetch_from_backup(location)
  rescue StandardError => e
    log_error("Weather service error", location: location, error: e)
    fetch_from_cache(location) || default_forecast
  end
  
  private
  
  def fetch_from_primary(location)
    response = HTTP.timeout(5).get("https://api.weather.com/forecast", params: { q: location })
    raise APIError, "HTTP #{response.code}" unless response.code == 200
    
    JSON.parse(response.body)
  rescue JSON::ParserError => e
    raise APIError, "Invalid JSON response: #{e.message}"
  end
  
  def fetch_from_backup(location)
    response = HTTP.timeout(5).get("https://backup.weather.com/forecast", params: { q: location })
    return nil unless response.code == 200
    
    JSON.parse(response.body)
  rescue => e
    log_warning("Backup service also failed", location: location, error: e)
    nil
  end
  
  def fetch_from_cache(location)
    cached = Rails.cache.read("forecast:#{location}")
    log_info("Using cached forecast", location: location) if cached
    cached
  end
  
  def default_forecast
    { temperature: nil, conditions: "unavailable", source: "default" }
  end
end

Design Considerations

Exception Cost vs Clarity: Exceptions provide clean separation between normal and error paths but impose performance overhead through stack unwinding. Return value checking avoids this cost but intermingles error handling with business logic. Choose exceptions for genuinely exceptional conditions where clarity outweighs performance cost.

Systems with high-frequency operations where errors occur regularly benefit from explicit return value checking. Web applications handling user input where validation failures are common might use result objects. Low-level libraries optimizing for performance might prefer error codes. High-level application code prioritizing maintainability typically uses exceptions.

# Exception-based: clean but potentially expensive
def process_item(item)
  validate!(item)
  transform(item)
  store(item)
end

# Return-based: explicit but verbose
def process_item(item)
  return false unless validate(item)
  result = transform(item)
  return false unless result
  store(result)
end

Fail-Fast vs Graceful Degradation: Fail-fast detects problems immediately, simplifying debugging but potentially disrupting service. Graceful degradation maintains functionality despite failures but risks operating in degraded states that compound problems.

Financial transactions, data integrity operations, and security-critical code benefit from fail-fast behavior. User-facing features, non-critical background jobs, and analytics collection can tolerate graceful degradation. The decision depends on the consequences of operating with partial functionality versus complete service interruption.

Local vs Centralized Handling: Local error handling at the call site provides immediate context and fine-grained control. Centralized handling in middleware or global handlers reduces duplication and enforces consistency. Modern applications often combine both: local handling for recoverable errors requiring specific responses, centralized handling for logging, monitoring, and fallback behavior.

# Local handling for specific recovery
def fetch_user_preferences(user_id)
  UserPreference.find_by(user_id: user_id)
rescue ActiveRecord::RecordNotFound
  UserPreference.create(user_id: user_id, settings: default_settings)
end

# Centralized handling in middleware
class ErrorHandlerMiddleware
  def call(env)
    @app.call(env)
  rescue ActiveRecord::RecordNotFound => e
    [404, { 'Content-Type' => 'application/json' }, [{ error: 'Not found' }.to_json]]
  rescue StandardError => e
    log_error(e)
    [500, { 'Content-Type' => 'application/json' }, [{ error: 'Internal error' }.to_json]]
  end
end

Type Safety vs Flexibility: Statically typed languages enforce error handling through checked exceptions or result types. Ruby's dynamic nature provides flexibility but sacrifices compile-time guarantees. Defensive programming, comprehensive testing, and explicit documentation compensate for the lack of type enforcement.

Common Pitfalls

Swallowing Exceptions: Catching exceptions without logging, re-raising, or handling them effectively hides failures. Silent failures prevent debugging, allow corruption to spread, and create mysterious system behavior. Every caught exception requires explicit decision about appropriate action.

# Problematic: silently ignores errors
def save_user(user)
  user.save
rescue => e
  # Nothing - error disappears
end

# Better: log and decide
def save_user(user)
  user.save
rescue ActiveRecord::RecordInvalid => e
  log_error("User validation failed", user: user, error: e)
  false
rescue StandardError => e
  log_error("Unexpected save error", user: user, error: e)
  raise  # Re-raise unexpected errors
end

Overly Broad Rescue: Catching Exception or rescuing without specifying exception types catches system-level exceptions like SignalException and NoMemoryError that should terminate the process. Broad rescue clauses also catch unexpected exception types, hiding bugs.

# Dangerous: catches everything including system signals
def process
  risky_operation
rescue Exception => e  # Too broad
  retry
end

# Better: catch specific expected exceptions
def process
  risky_operation
rescue NetworkError, TimeoutError => e
  retry
end

Exception for Control Flow: Using exceptions as conditional logic creates performance problems and obscures program structure. Exceptions should represent exceptional conditions, not alternative execution paths.

# Misuse: exception as flow control
def find_user(id)
  users.find { |u| u.id == id } || raise UserNotFound
rescue UserNotFound
  create_default_user
end

# Better: explicit conditional
def find_user(id)
  users.find { |u| u.id == id } || create_default_user
end

Lost Exception Context: Re-raising exceptions with new exception instances discards the original backtrace. Wrapping exceptions should preserve the causal chain.

# Loses original backtrace
def process
  risky_operation
rescue RuntimeError => e
  raise CustomError.new("Processing failed")  # Original trace lost
end

# Preserves context with cause
def process
  risky_operation
rescue RuntimeError => e
  raise CustomError.new("Processing failed: #{e.message}"), cause: e
end

Inconsistent Error States: Operations that modify state then fail leave systems in inconsistent conditions. Transactions, compensating actions, or copy-on-write patterns maintain consistency across failures.

# Problematic: partial state modification
def update_profile(user, params)
  user.name = params[:name]
  user.email = params[:email]
  user.save!  # Fails after partial updates
end

# Better: atomic update
def update_profile(user, params)
  user.update!(name: params[:name], email: params[:email])
end

Resource Leaks: Failing to release resources (files, connections, locks) when exceptions occur creates leaks. Block-based resource management or explicit ensure clauses prevent leaks.

# Leak risk: exception prevents close
def process_file(path)
  file = File.open(path)
  process(file)
  file.close
end

# Safe: ensure guarantees cleanup
def process_file(path)
  file = File.open(path)
  process(file)
ensure
  file&.close
end

# Best: block-based automatic cleanup
def process_file(path)
  File.open(path) do |file|
    process(file)
  end
end

Reference

Exception Hierarchy

Exception Class Purpose Rescue By Default
Exception Base class for all exceptions No
StandardError Base for application exceptions Yes
ArgumentError Invalid method arguments Yes
RuntimeError Generic runtime error Yes
TypeError Type mismatch Yes
NoMethodError Method not found Yes
IOError Input/output error Yes
SystemCallError System call failed Yes
NoMemoryError Out of memory No
SignalException Signal received No
Interrupt User interrupt (Ctrl+C) No

Error Handling Syntax

Syntax Purpose Example
begin...rescue...end Handle exceptions in block begin; code; rescue => e; handle; end
rescue modifier Inline exception handling result = code rescue default
def...rescue...end Method-level exception handling def method; code; rescue => e; handle; end
raise Raise exception raise ArgumentError, "message"
raise (no args) Re-raise current exception rescue => e; log(e); raise
ensure Always execute cleanup begin; code; ensure; cleanup; end
retry Restart begin block begin; code; rescue; retry; end
throw/catch Non-local exit catch(:done) { throw(:done) }

Result Object Methods

Method Returns Purpose
Result.success(value) Result instance Create successful result
Result.failure(error) Result instance Create failed result
result.success? Boolean Check if operation succeeded
result.failure? Boolean Check if operation failed
result.value Any Get success value
result.error Any Get error information

Circuit Breaker States

State Behavior Transition
Closed Normal operation, requests pass through Opens after threshold failures
Open Requests immediately rejected Half-opens after timeout
Half-Open Test request allowed Closes on success, opens on failure

Retry Strategy Parameters

Parameter Purpose Typical Values
max_attempts Maximum retry count 3-5
base_delay Initial delay in seconds 1-2
max_delay Delay ceiling in seconds 30-60
backoff_factor Exponential multiplier 2
jitter Random delay variance 0.1-0.3 (10-30%)

Exception Safety Guarantees

Guarantee Definition Implementation
Basic No resource leaks, invariants may be violated Use ensure for cleanup
Strong Transactional - complete success or no change Copy state, apply on success
No-throw Never raises exceptions Catch all, return error codes