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 |