Overview
Error handling paradigms represent distinct philosophical approaches to managing exceptional conditions and failures in software systems. Each paradigm embodies different assumptions about error frequency, recovery strategies, and the relationship between error detection and program flow. The choice of paradigm affects code structure, debugging practices, system reliability, and the developer experience of both writing and maintaining error-prone code.
The primary paradigms include exception-based handling, error code returns, result types, option types, and panic/abort strategies. Exception-based systems use language-level control flow mechanisms to propagate errors up the call stack. Error code approaches return status values that callers must explicitly check. Result types encapsulate success or failure states in type-safe containers. Option types handle absence of values without representing errors. Panic strategies terminate execution when recovery is impossible.
Ruby adopts an exception-centric paradigm, providing comprehensive exception classes, structured error propagation through begin/rescue/ensure blocks, and convention-driven error signaling. This approach prioritizes clean separation between normal flow and error handling logic, automatic stack unwinding, and rich error context through exception objects.
# Exception-based paradigm in Ruby
def process_payment(amount)
raise ArgumentError, "Amount must be positive" if amount <= 0
raise InsufficientFundsError if account_balance < amount
deduct_funds(amount)
record_transaction(amount)
rescue InsufficientFundsError => e
notify_insufficient_funds
raise
rescue NetworkError => e
retry_with_backoff(3)
end
The paradigm choice influences error granularity, composition patterns, and recovery strategies. Systems requiring fine-grained error handling may benefit from result types that force explicit handling. Applications prioritizing rapid failure detection often employ exception-based approaches. Performance-critical code paths sometimes avoid exceptions due to overhead concerns. Domain characteristics, team conventions, and language idioms guide paradigm selection.
Key Principles
Error handling paradigms rest on distinct principles about error representation, propagation, and recovery. These principles define how systems distinguish between expected exceptional conditions and programmer errors, how errors traverse program layers, and what actions constitute appropriate responses to failures.
Error vs Exception Distinction: Some paradigms differentiate between errors (expected failure conditions) and exceptions (unexpected programmer errors). Errors represent anticipated failure modes such as network timeouts, file not found, or invalid input. Exceptions indicate bugs, contract violations, or impossible states. This distinction affects whether conditions should propagate immediately or allow local recovery.
Explicit vs Implicit Propagation: Paradigms differ in whether error propagation requires explicit acknowledgment at each level. Exception-based systems propagate implicitly through stack unwinding until caught. Error code and result type approaches require explicit checking or forwarding at each call site. Implicit propagation reduces boilerplate but may hide error paths. Explicit handling increases verbosity but makes error flow visible.
Fail Fast vs Resilient Handling: Systems balance between failing immediately upon detecting errors versus attempting recovery. Fail fast approaches terminate or propagate errors quickly to prevent corrupted state. Resilient handling implements retry logic, fallbacks, and degraded operation modes. The principle applies differently across system layers—lower layers often fail fast while higher layers attempt recovery.
# Fail fast principle
def validate_configuration(config)
raise ConfigError, "Missing required field: api_key" unless config[:api_key]
raise ConfigError, "Invalid timeout value" unless config[:timeout].positive?
config
end
# Resilient handling principle
def fetch_with_fallback(primary_url, fallback_url)
fetch(primary_url)
rescue NetworkError
fetch(fallback_url)
rescue => e
return_cached_data
end
Error Context and Causality: Different paradigms provide varying mechanisms for preserving error context during propagation. Exception objects carry stack traces, error messages, and causal chains. Error codes lose context unless explicitly threaded through return values. Result types can wrap error details but require discipline to maintain context chains. Rich context aids debugging but imposes overhead.
Recoverability and Finalization: Paradigms handle resource cleanup and recovery differently. Exception systems provide finally/ensure blocks for deterministic cleanup. Error code approaches require explicit cleanup at each return point. Result types compose cleanup operations in the success/failure paths. The principle affects transaction safety, resource leak prevention, and rollback strategies.
Type System Integration: Statically typed languages integrate error handling with type checking. Result types make errors part of function signatures, enabling compile-time verification of handling. Exception specifications (where supported) declare possible exceptions. Dynamic languages like Ruby trade compile-time safety for flexibility, relying on convention and testing to ensure proper handling.
Ruby Implementation
Ruby implements exception-based error handling as the primary paradigm, built on the Exception class hierarchy and begin/rescue/ensure/else control structures. The implementation emphasizes clean separation between normal code flow and error handling logic, with exceptions propagating automatically through the call stack until caught or terminating the program.
The Exception class hierarchy organizes errors by recoverability and intent. StandardError and its descendants represent recoverable application errors that rescue blocks catch by default. More severe errors like SystemExit, SignalException, and NoMemoryError descend directly from Exception and bypass default rescue clauses. This hierarchy prevents accidental suppression of critical system errors.
# Ruby exception hierarchy usage
begin
process_user_input(data)
rescue ArgumentError => e
# Handles specific error type
log_validation_error(e)
return_error_response
rescue StandardError => e
# Catches all standard errors
log_error(e)
raise # Re-raise for higher-level handling
rescue Exception => e
# Catches everything (dangerous - avoid in application code)
cleanup_critical_resources
raise
end
The begin/rescue/ensure/else structure provides comprehensive control over error handling flow. The begin block contains code that might raise exceptions. Rescue clauses catch specific exception types in order of specificity. The else clause executes only if no exceptions occur. The ensure clause always executes for cleanup, regardless of exceptions or early returns.
def read_and_process_file(path)
file = File.open(path)
data = file.read
begin
result = parse_json(data)
validate_schema(result)
rescue JSON::ParserError => e
raise DataFormatError, "Invalid JSON in #{path}: #{e.message}"
rescue ValidationError => e
log_validation_failure(e)
return default_value
else
# Only executes if no exceptions raised
cache_result(result)
result
ensure
# Always executes
file.close if file
end
end
Ruby raises exceptions with the raise method, accepting an exception class, message, and optional backtrace. The fail method is an alias for raise, sometimes used to signal programmer errors versus expected failures. Custom exception classes inherit from StandardError to enable selective rescue while remaining catchable by default handlers.
# Custom exception classes
class PaymentError < StandardError; end
class InsufficientFundsError < PaymentError; end
class PaymentGatewayError < PaymentError; end
def process_payment(amount, account)
raise ArgumentError, "Amount must be positive" unless amount.positive?
if account.balance < amount
raise InsufficientFundsError.new("Balance #{account.balance} insufficient for #{amount}")
end
gateway_response = payment_gateway.charge(amount)
unless gateway_response.success?
raise PaymentGatewayError, "Gateway returned: #{gateway_response.error_code}"
end
gateway_response
end
Ruby supports exception chaining through the cause attribute, preserving causal relationships when rescuing and re-raising. This maintains error context across abstraction boundaries while allowing higher-level code to work with domain-appropriate exception types.
def fetch_user_profile(user_id)
response = http_client.get("/users/#{user_id}")
JSON.parse(response.body)
rescue HTTP::Error => e
raise ProfileFetchError, "Failed to retrieve profile for user #{user_id}", cause: e
rescue JSON::ParserError => e
raise ProfileFetchError, "Invalid profile data for user #{user_id}", cause: e
end
# Accessing the cause chain
begin
profile = fetch_user_profile(123)
rescue ProfileFetchError => e
puts "Error: #{e.message}"
puts "Caused by: #{e.cause.message}" if e.cause
puts "Backtrace: #{e.backtrace.first(5)}"
end
The throw/catch mechanism provides non-local control flow distinct from exception handling. While syntactically similar, throw/catch handles control flow for expected conditions like early termination of deeply nested loops, not exceptional errors. Confusing throw/catch with exception handling leads to semantic errors.
# throw/catch for control flow (not error handling)
def find_in_nested_structure(data)
catch(:found) do
data.each do |level1|
level1.each do |level2|
level2.each do |item|
throw(:found, item) if item.matches_criteria?
end
end
end
nil # Not found
end
end
Ruby's retry keyword allows rescue blocks to retry the begin block, useful for transient failures like network errors. The retry jumps back to the begin statement, potentially creating infinite loops without proper limiting logic.
def fetch_with_retry(url, max_attempts: 3)
attempts = 0
begin
attempts += 1
http_get(url)
rescue Timeout::Error, NetworkError => e
if attempts < max_attempts
sleep(2 ** attempts) # Exponential backoff
retry
else
raise
end
end
end
Common Patterns
Error handling patterns provide reusable strategies for common failure scenarios. These patterns address exception wrapping, retry logic, resource cleanup, and error recovery at different architectural levels.
Exception Translation Pattern wraps low-level exceptions in domain-appropriate exception types, isolating implementation details from higher layers. This pattern maintains abstraction boundaries while preserving error causality through exception chaining.
class RepositoryError < StandardError; end
class RecordNotFoundError < RepositoryError; end
class DatabaseConnectionError < RepositoryError; end
class UserRepository
def find(user_id)
result = database.query("SELECT * FROM users WHERE id = ?", user_id)
raise RecordNotFoundError, "User #{user_id} not found" if result.empty?
User.new(result.first)
rescue PG::ConnectionBad => e
raise DatabaseConnectionError, "Database unavailable", cause: e
rescue PG::Error => e
raise RepositoryError, "Database error: #{e.message}", cause: e
end
end
Retry with Exponential Backoff handles transient failures by retrying operations with increasing delays. This pattern prevents overwhelming failing services while allowing recovery from temporary issues. The pattern includes maximum retry limits and exponential delay calculations.
def with_retry(max_attempts: 3, base_delay: 1, max_delay: 30)
attempts = 0
begin
attempts += 1
yield
rescue *retryable_exceptions => e
if attempts < max_attempts
delay = [base_delay * (2 ** (attempts - 1)), max_delay].min
sleep(delay + rand(0..delay * 0.1)) # Add jitter
retry
else
raise
end
end
end
# Usage
with_retry(max_attempts: 5) do
api_client.fetch_data
end
Circuit Breaker Pattern prevents cascading failures by stopping requests to failing services. The pattern tracks failure rates and transitions between closed (normal), open (failing), and half-open (testing recovery) states.
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
@last_failure_time = nil
end
def call
case @state
when :open
if Time.now - @last_failure_time > @timeout
@state = :half_open
attempt_call
else
raise CircuitOpenError, "Circuit breaker open"
end
when :closed, :half_open
attempt_call
end
end
private
def attempt_call
result = yield
on_success
result
rescue => e
on_failure
raise
end
def on_success
@failure_count = 0
@state = :closed
end
def on_failure
@failure_count += 1
@last_failure_time = Time.now
if @failure_count >= @failure_threshold
@state = :open
end
end
end
Null Object Pattern handles absence of values without nil checks or exceptions. The pattern provides objects implementing expected interfaces but with neutral behavior, eliminating special-case handling.
class User
attr_reader :name, :email
def initialize(name, email)
@name = name
@email = email
end
def admin?
false
end
end
class NullUser
def name
"Guest"
end
def email
nil
end
def admin?
false
end
end
def find_user(user_id)
result = database.query("SELECT * FROM users WHERE id = ?", user_id)
return NullUser.new if result.empty?
User.new(result[:name], result[:email])
end
# Usage eliminates nil checks
user = find_user(123)
puts "Welcome, #{user.name}" # No nil check needed
Result Type Pattern encapsulates success or failure states in type-safe containers, making error handling explicit in function signatures. Ruby implementations use custom classes since Ruby lacks built-in result types.
class Result
def self.success(value)
Success.new(value)
end
def self.failure(error)
Failure.new(error)
end
end
class Success < Result
attr_reader :value
def initialize(value)
@value = value
end
def success?
true
end
def failure?
false
end
def map
Result.success(yield(value))
rescue => e
Result.failure(e)
end
end
class Failure < Result
attr_reader :error
def initialize(error)
@error = error
end
def success?
false
end
def failure?
true
end
def map
self
end
end
# Usage
def parse_json(data)
Result.success(JSON.parse(data))
rescue JSON::ParserError => e
Result.failure(e)
end
result = parse_json(input)
result.map { |data| process_data(data) }
.map { |processed| save_to_database(processed) }
Let It Crash Pattern allows subsystems to fail completely rather than attempting recovery, relying on supervision to restart failed components. This pattern works well in concurrent systems where isolating failures prevents corruption.
class Worker
def perform(task)
# No error handling - let exceptions propagate
result = process_task(task)
save_result(result)
end
end
class Supervisor
def initialize(worker_class, max_restarts: 3)
@worker_class = worker_class
@max_restarts = max_restarts
@restart_count = 0
end
def supervise(task)
worker = @worker_class.new
worker.perform(task)
rescue => e
handle_worker_failure(e, task)
end
private
def handle_worker_failure(error, task)
@restart_count += 1
if @restart_count < @max_restarts
log_error("Worker failed, restarting", error)
supervise(task)
else
log_error("Worker exceeded restart limit", error)
raise
end
end
end
Practical Examples
Real-world error handling requires combining multiple patterns and handling complex failure scenarios across system boundaries. These examples demonstrate comprehensive approaches to common error handling challenges.
HTTP API Client with Comprehensive Error Handling manages network failures, timeouts, rate limiting, and response parsing while providing clear error messages to callers.
class APIClientError < StandardError; end
class NetworkError < APIClientError; end
class RateLimitError < APIClientError; end
class AuthenticationError < APIClientError; end
class APIResponseError < APIClientError; end
class APIClient
def initialize(base_url, api_key, timeout: 30)
@base_url = base_url
@api_key = api_key
@timeout = timeout
@circuit_breaker = CircuitBreaker.new(failure_threshold: 3, timeout: 60)
end
def get(endpoint, params = {})
with_retry(max_attempts: 3) do
@circuit_breaker.call do
response = execute_request(:get, endpoint, params)
parse_response(response)
end
end
rescue Timeout::Error => e
raise NetworkError, "Request timeout after #{@timeout}s for #{endpoint}", cause: e
rescue SocketError, Errno::ECONNREFUSED => e
raise NetworkError, "Connection failed: #{e.message}", cause: e
rescue CircuitOpenError => e
raise NetworkError, "Service unavailable (circuit breaker open)", cause: e
end
private
def execute_request(method, endpoint, params)
uri = URI.join(@base_url, endpoint)
uri.query = URI.encode_www_form(params) if params.any?
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
http.read_timeout = @timeout
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "Bearer #{@api_key}"
request['User-Agent'] = 'APIClient/1.0'
http.request(request)
end
def parse_response(response)
case response.code.to_i
when 200..299
JSON.parse(response.body)
when 401, 403
raise AuthenticationError, "Authentication failed: #{response.message}"
when 429
retry_after = response['Retry-After'].to_i
raise RateLimitError, "Rate limit exceeded, retry after #{retry_after}s"
when 500..599
raise APIResponseError, "Server error: #{response.code} #{response.message}"
else
raise APIResponseError, "Unexpected response: #{response.code} #{response.message}"
end
rescue JSON::ParserError => e
raise APIResponseError, "Invalid JSON response", cause: e
end
def with_retry(max_attempts:)
attempts = 0
begin
attempts += 1
yield
rescue NetworkError, APIResponseError => e
if attempts < max_attempts && retryable_error?(e)
sleep(2 ** attempts)
retry
else
raise
end
end
end
def retryable_error?(error)
error.is_a?(NetworkError) ||
(error.is_a?(APIResponseError) && error.message.include?('Server error'))
end
end
Database Transaction with Rollback Handling manages transactional integrity, connection failures, and concurrent access issues while ensuring resource cleanup.
class DatabaseTransaction
class TransactionError < StandardError; end
class RollbackError < TransactionError; end
class DeadlockError < TransactionError; end
def initialize(connection)
@connection = connection
end
def execute
@connection.begin_transaction
result = yield
@connection.commit
result
rescue ActiveRecord::Rollback => e
# Expected rollback, not an error
@connection.rollback
nil
rescue ActiveRecord::StatementInvalid => e
@connection.rollback
if deadlock_detected?(e)
raise DeadlockError, "Database deadlock detected", cause: e
else
raise TransactionError, "SQL error: #{e.message}", cause: e
end
rescue => e
# Rollback on any unexpected error
begin
@connection.rollback
rescue => rollback_error
# Rollback itself failed - critical situation
log_critical_error("Rollback failed after error", rollback_error)
raise RollbackError, "Transaction rollback failed: #{rollback_error.message}", cause: e
end
raise TransactionError, "Transaction failed: #{e.message}", cause: e
ensure
# Release connection back to pool
@connection.release if @connection.active?
end
private
def deadlock_detected?(error)
error.message.include?('deadlock') || error.message.include?('lock timeout')
end
def log_critical_error(message, error)
# Log to monitoring system
puts "CRITICAL: #{message} - #{error.class}: #{error.message}"
end
end
# Usage with automatic retry on deadlock
def transfer_funds(from_account, to_account, amount, max_attempts: 3)
attempts = 0
begin
attempts += 1
DatabaseTransaction.new(ActiveRecord::Base.connection).execute do
from_account.balance -= amount
from_account.save!
to_account.balance += amount
to_account.save!
Transaction.create!(
from_account: from_account,
to_account: to_account,
amount: amount
)
end
rescue DeadlockError => e
if attempts < max_attempts
sleep(rand(0.1..0.5)) # Random backoff to break deadlock cycle
retry
else
raise TransferError, "Transfer failed after #{attempts} attempts due to deadlock", cause: e
end
end
end
File Processing Pipeline with Partial Failure Handling processes multiple files while tracking individual failures, continuing processing where possible, and generating comprehensive error reports.
class FileProcessor
class ProcessingError < StandardError
attr_reader :failed_files, :successful_files
def initialize(message, failed_files, successful_files)
super(message)
@failed_files = failed_files
@successful_files = successful_files
end
end
def process_batch(file_paths)
results = {
successful: [],
failed: [],
errors: {}
}
file_paths.each do |path|
begin
process_single_file(path)
results[:successful] << path
rescue => e
results[:failed] << path
results[:errors][path] = {
error_class: e.class.name,
message: e.message,
backtrace: e.backtrace.first(3)
}
# Log but continue processing other files
log_file_error(path, e)
end
end
if results[:failed].any?
error_summary = generate_error_summary(results)
raise ProcessingError.new(error_summary, results[:failed], results[:successful])
end
results[:successful]
end
private
def process_single_file(path)
file = nil
begin
validate_file_exists(path)
validate_file_readable(path)
file = File.open(path)
data = parse_file(file)
validate_data(data)
transform_data(data)
save_processed_data(data)
ensure
file&.close
end
end
def validate_file_exists(path)
raise ArgumentError, "File not found: #{path}" unless File.exist?(path)
end
def validate_file_readable(path)
raise IOError, "File not readable: #{path}" unless File.readable?(path)
end
def parse_file(file)
JSON.parse(file.read)
rescue JSON::ParserError => e
raise FormatError, "Invalid JSON format", cause: e
end
def validate_data(data)
raise ValidationError, "Missing required fields" unless data['id'] && data['content']
raise ValidationError, "Invalid data type" unless data['content'].is_a?(String)
end
def generate_error_summary(results)
"Processed #{results[:successful].length} files successfully, #{results[:failed].length} failed:\n" +
results[:errors].map { |path, error| " #{path}: #{error[:error_class]} - #{error[:message]}" }.join("\n")
end
end
Design Considerations
Selecting an error handling paradigm requires evaluating trade-offs between explicit error visibility, recovery capability, performance overhead, and code complexity. Different paradigms suit different contexts based on error frequency, system reliability requirements, and architectural constraints.
Exception-Based vs Error Code Trade-offs represent fundamental design choices. Exceptions provide automatic propagation, stack unwinding, and clean separation of error handling from normal flow. Error codes require explicit checking at each level but make error paths visible in code structure. Exceptions excel when errors are truly exceptional (rare) and require propagating through many layers. Error codes work better when errors are frequent, expected, or when explicit control flow visibility matters.
Ruby's exception-based approach assumes errors occur infrequently enough that exception overhead is acceptable. The paradigm breaks down when exceptions serve as control flow for common cases like validation or parsing, where error codes or result types perform better.
# Exception-based: clean normal flow, implicit propagation
def process_order(order_data)
order = validate_order(order_data) # May raise ValidationError
payment = process_payment(order) # May raise PaymentError
fulfill_order(order, payment) # May raise FulfillmentError
end
# Error code style in Ruby (uncommon but viable)
def process_order(order_data)
order, error = validate_order(order_data)
return [nil, error] if error
payment, error = process_payment(order)
return [nil, error] if error
fulfill_order(order, payment)
end
Fail Fast vs Resilient Recovery trade safety against availability. Fail fast approaches terminate immediately upon detecting errors, preventing corrupt state propagation. Resilient strategies attempt recovery through retries, fallbacks, or degraded operation modes. Lower architectural layers typically fail fast—database layers, validation logic, core business rules. Higher layers implement resilience—API gateways retry transient failures, applications provide fallback data, user interfaces offer degraded experiences.
The decision depends on error context. Transient network failures merit retry logic. Data corruption merits immediate failure. User input errors merit validation feedback. System resource exhaustion merits graceful degradation or load shedding.
# Fail fast: validation layer
class OrderValidator
def validate(order)
raise ValidationError, "Missing customer" unless order.customer_id
raise ValidationError, "Negative amount" if order.amount < 0
raise ValidationError, "Invalid item count" if order.items.empty?
order # No recovery attempted
end
end
# Resilient: service layer
class OrderService
def create_order(order_data)
order = OrderValidator.new.validate(order_data)
begin
payment_service.charge(order)
rescue PaymentServiceUnavailable => e
fallback_payment_provider.charge(order)
rescue PaymentDeclined => e
notify_customer_declined(order.customer_id)
raise
end
end
end
Local vs Central Error Handling determines where error handling logic resides. Local handling places rescue blocks near error sites, providing context-specific recovery. Central handling uses global exception handlers or middleware for consistent treatment. Local handling offers flexibility but risks inconsistency. Central handling ensures uniformity but loses context.
Web applications typically combine both—controllers handle request-specific errors locally while middleware catches unhandled exceptions globally. Background job systems often centralize retry logic while workers handle domain-specific errors locally.
Checked vs Unchecked Exceptions (in languages supporting the distinction) affect whether callers must acknowledge possible exceptions. Ruby treats all exceptions as unchecked—callers need not declare or handle them. This flexibility comes at the cost of discoverability—documentation and testing become critical for understanding error conditions. Statically typed languages with checked exceptions provide compile-time verification of error handling but increase verbosity.
Error Granularity and Exception Hierarchies balance specificity against proliferation. Fine-grained exception types enable targeted handling but create maintenance burden. Coarse-grained types simplify code but reduce handling precision. Design exception hierarchies around recoverability and handling strategies, not just error sources.
# Granular hierarchy enables selective handling
class PaymentError < StandardError; end
class InsufficientFundsError < PaymentError; end
class CardExpiredError < PaymentError; end
class FraudDetectedError < PaymentError; end
def handle_payment_error(error)
case error
when InsufficientFundsError
suggest_alternative_payment_method
when CardExpiredError
prompt_card_update
when FraudDetectedError
escalate_to_security_team
else
generic_payment_failure_handling
end
end
Performance Considerations influence paradigm choice in performance-critical code. Exception raising and unwinding incurs overhead—creating exception objects, unwinding the stack, and populating backtraces consumes CPU cycles. Hot paths handling frequent expected failures benefit from error codes or result types. Infrequent exceptional conditions tolerate exception overhead in exchange for cleaner code structure.
Ruby exceptions are relatively expensive compared to return value checks. Profiling reveals whether exception overhead matters for specific use cases. Avoid using exceptions for flow control in tight loops or high-frequency operations.
Common Pitfalls
Error handling mistakes often stem from incomplete error coverage, incorrect recovery logic, or misuse of exception mechanisms. Understanding these pitfalls prevents subtle bugs and system failures.
Swallowing Exceptions occurs when rescue blocks catch errors without proper handling, hiding failures. Empty rescue blocks or generic handlers that log and continue execution create silent failures. This pitfall appears in code attempting to make systems "more robust" by catching everything.
# Dangerous: swallows all errors
def process_data(data)
transform(data)
save_to_database(data)
rescue
# Error disappeared, no indication of failure
end
# Better: handle specifically or re-raise
def process_data(data)
transform(data)
save_to_database(data)
rescue ValidationError => e
log_validation_failure(e)
raise # Propagate to caller
end
Catching Exception Instead of StandardError catches system-level exceptions that should not be rescued, including Interrupt, SystemExit, and SignalException. This prevents clean shutdown, breaks testing frameworks, and interferes with signal handling.
# Wrong: catches everything including system signals
begin
run_server
rescue Exception => e
log_error(e)
retry # May prevent clean shutdown
end
# Correct: catches application errors only
begin
run_server
rescue StandardError => e
log_error(e)
notify_monitoring
raise
end
Bare Retry Creates Infinite Loops when rescue blocks use retry without limiting attempts or addressing error causes. Transient failures like network errors justify retry; persistent errors like validation failures do not.
# Dangerous: infinite loop on persistent errors
def fetch_data
begin
api_client.get('/data')
rescue NetworkError
sleep(1)
retry # Retries forever
end
end
# Correct: limit attempts and verify transient nature
def fetch_data(max_attempts = 3)
attempts = 0
begin
attempts += 1
api_client.get('/data')
rescue NetworkError => e
if attempts < max_attempts
sleep(2 ** attempts)
retry
else
raise
end
end
end
Losing Error Context During Re-raising occurs when rescue blocks raise new exceptions without preserving the original cause, breaking error chains and losing debugging information.
# Wrong: loses original error information
begin
parse_json(data)
rescue JSON::ParserError
raise DataError, "Failed to parse" # Original context lost
end
# Correct: preserve cause chain
begin
parse_json(data)
rescue JSON::ParserError => e
raise DataError, "Failed to parse: #{e.message}", cause: e
end
Rescuing During Ensure Execution creates confusing control flow when ensure blocks contain code that might raise exceptions. Exceptions raised in ensure blocks override exceptions being propagated from the begin block.
# Problematic: cleanup error masks original error
def process_with_cleanup
begin
risky_operation
ensure
cleanup_operation # If this raises, original error lost
end
end
# Better: rescue within ensure to prevent masking
def process_with_cleanup
begin
risky_operation
ensure
begin
cleanup_operation
rescue => e
log_error("Cleanup failed: #{e.message}")
# Original error still propagates
end
end
end
Using Exceptions for Control Flow employs exceptions for non-exceptional conditions like validation, lookup failures, or iteration control. This misuse degrades performance and obscures actual errors in logs and monitoring.
# Wrong: using exceptions for expected lookup failures
def find_user(email)
User.find_by!(email: email)
rescue ActiveRecord::RecordNotFound
nil
end
# Correct: use methods designed for optional results
def find_user(email)
User.find_by(email: email)
end
Inconsistent Error Handling Across Layers creates unpredictable behavior when different system layers follow different error handling conventions. Some layers might return nil, others raise exceptions, others return error codes, confusing callers about expected behavior.
# Inconsistent: repository returns nil, service raises
class UserRepository
def find(id)
User.find_by(id: id) # Returns nil if not found
end
end
class UserService
def get_user(id)
user = repository.find(id)
raise NotFoundError unless user # Inconsistent with repository
user
end
end
# Consistent: repository raises, service propagates
class UserRepository
def find(id)
User.find_by!(id: id) # Raises if not found
end
end
class UserService
def get_user(id)
repository.find(id) # Propagates exception
end
end
Not Validating Inputs Before Expensive Operations performs costly operations before checking inputs, wasting resources when validation fails. Validate early, fail fast for input errors before acquiring resources or performing irreversible actions.
Ignoring Exception Hierarchy catches broad exception classes when specific types would enable better handling. Catching StandardError when specific exceptions like ValidationError or NetworkError are expected prevents targeted recovery.
Reference
Exception Hierarchy
| Exception Class | Catchable by Default | Purpose | Recovery Strategy |
|---|---|---|---|
| Exception | No | Root of all exceptions | Never rescue except for logging |
| StandardError | Yes | Recoverable application errors | Rescue and handle appropriately |
| ArgumentError | Yes | Invalid arguments passed | Validate inputs, fix caller |
| RuntimeError | Yes | Generic runtime error | Examine context, fix root cause |
| IOError | Yes | Input/output failures | Retry or provide fallback |
| NoMethodError | Yes | Method called on nil/wrong type | Check object state, validate types |
| SystemExit | No | Program exit requested | Allow propagation for clean exit |
| SignalException | No | System signal received | Allow propagation for signal handling |
| Interrupt | No | User interrupt (Ctrl-C) | Allow propagation for graceful shutdown |
Control Flow Keywords
| Keyword | Purpose | Scope | Execution Timing |
|---|---|---|---|
| begin | Starts exception handling block | Defines protected region | Executes first |
| rescue | Catches specific exception types | Handles matched exceptions | After exception raised |
| else | Executes on success only | Runs if no exceptions | After begin, before ensure |
| ensure | Cleanup code that always runs | Executes regardless of exceptions | Always executes last |
| raise | Raises an exception | Propagates error up stack | Immediate propagation |
| retry | Retries begin block | Jumps back to begin | After rescue |
| throw/catch | Non-local control flow (not errors) | Control flow between blocks | Immediate jump to catch |
Custom Exception Patterns
| Pattern | Inheritance | Use Case | Example |
|---|---|---|---|
| Domain Errors | StandardError | Application-specific errors | PaymentError, ValidationError |
| Nested Hierarchies | Custom base classes | Categorized error handling | PaymentError > InsufficientFundsError |
| Error with Attributes | Add attr_reader | Attach error context | InvalidRequestError with request_id |
| Cause Preservation | Use cause parameter | Maintain error chains | Wrap low-level exceptions |
Error Handling Decision Matrix
| Condition | Strategy | Rationale |
|---|---|---|
| Expected failures (validation, not found) | Return nil or result object | Not exceptional, avoid exception overhead |
| Transient failures (network, timeout) | Retry with exponential backoff | Temporary condition, likely recoverable |
| Resource exhaustion (memory, connections) | Fail fast, propagate immediately | Cannot recover, prevent cascade |
| Data corruption | Fail fast, log extensively | Unrecoverable, needs investigation |
| Configuration errors | Fail at startup | Invalid state, fix before running |
| User input errors | Return validation errors | Expected, provide feedback |
| Programming errors (nil reference) | Propagate exception | Bug, needs fixing |
| External service failures | Circuit breaker pattern | Prevent cascade, enable recovery |
Rescue Clause Ordering
| Order | Exception Type | Reason |
|---|---|---|
| 1 | Most specific subclass | Matches narrowest exception first |
| 2 | Parent classes | Catches broader categories |
| 3 | StandardError | Catches all application errors |
| Never | Exception | Catches system exceptions (dangerous) |
Exception Raising Forms
# Message only (creates RuntimeError)
raise "Error message"
# Exception class and message
raise ArgumentError, "Invalid argument"
# Exception instance
raise CustomError.new("Message", additional_context: data)
# Re-raise current exception
raise
# With cause chain
raise NewError, "Message", cause: original_error
Ensure Clause Guarantees
| Scenario | Ensure Executes | Exception Propagates |
|---|---|---|
| No exception raised | Yes | No |
| Exception raised and rescued | Yes | No (unless re-raised) |
| Exception raised, not rescued | Yes | Yes |
| Early return from begin | Yes | No |
| Exception in rescue clause | Yes | Yes (new exception) |
Performance Characteristics
| Operation | Relative Cost | Use When |
|---|---|---|
| Exception raising | High | Truly exceptional conditions |
| Exception catching | Medium | Expected frequency under 1% |
| Stack unwinding | High | Deep call stacks with exceptions |
| Return value checking | Low | Frequent expected failures |
| Result type pattern matching | Low to Medium | Explicit error handling needed |