Understanding Error Handling in Ruby
Error handling is the structured approach to anticipating, detecting, and responding to exceptional conditions in your code. In Ruby, error handling revolves around the begin, rescue, ensure, and raise constructs, combined with Ruby's rich exception class hierarchy. Unlike some languages where errors are returned as special values, Ruby uses exceptionsβobjects that propagate up the call stack until caught by a matching rescue clause.
At its core, error handling in Ruby is about writing code that fails gracefully rather than crashing silently. It transforms unpredictable failures into predictable, controllable outcomes that your application can manage.
Why Error Handling Matters
Without proper error handling, even a minor hiccupβa missing file, a network timeout, or unexpected user inputβcan crash your entire application. Robust error handling provides several critical benefits:
- User Experience: Users receive meaningful feedback instead of cryptic stack traces
- Data Integrity: Transactions can be rolled back properly when something goes wrong
- Debugging: Structured error information with context makes troubleshooting faster
- Resilience: Applications can retry, fall back to degraded modes, or self-heal
- Security: Controlled error responses prevent leaking sensitive internal details
The Fundamentals: begin/rescue/ensure/raise
π Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →The most basic error handling pattern in Ruby is the begin...rescue...ensure block. Code that might fail goes in the begin section, recovery logic lives in rescue, and cleanup that must always run sits in ensure.
def read_config_file(path)
config_data = nil
begin
file = File.open(path, "r")
config_data = file.read
puts "Config loaded successfully"
rescue Errno::ENOENT => e
puts "Config file not found: #{e.message}"
config_data = generate_default_config
rescue Errno::EACCES => e
puts "Permission denied: #{e.message}"
raise # Re-raise if we can't handle it
rescue StandardError => e
puts "Unexpected error reading config: #{e.class} - #{e.message}"
config_data = {}
ensure
file.close if file && !file.closed?
puts "Cleanup complete"
end
config_data
end
# Usage
result = read_config_file("/etc/myapp/config.yml")
Notice how rescue clauses match exceptions from most specific to least specific. The ensure block guarantees file cleanup regardless of whether an exception occurred. The => syntax captures the exception object for inspection.
Single-Line Rescue
For quick, localized handling, Ruby offers a concise single-line rescue syntax:
def safe_divide(a, b)
result = a / b rescue nil
result
end
# Equivalent to:
def safe_divide_verbose(a, b)
begin
a / b
rescue ZeroDivisionError
nil
end
end
puts safe_divide(10, 0) # => nil
puts safe_divide(10, 2) # => 5
While convenient, the single-line rescue is limitedβit rescues StandardError and returns the expression after rescue. Use it sparingly in simple cases; for anything complex, prefer the block form.
Raising Exceptions
The raise keyword (or its alias fail) creates and throws exceptions. You can raise built-in exception types or custom ones:
def validate_age(age)
raise ArgumentError, "Age must be a positive integer" unless age.is_a?(Integer)
raise ArgumentError, "Age must be between 0 and 150" if age < 0 || age > 150
raise "Invalid age provided" # Raises RuntimeError with the message
"Valid age: #{age}"
end
# With a custom exception class
class ValidationError < StandardError
attr_reader :field, :value
def initialize(field, value, message = nil)
@field = field
@value = value
super(message || "Validation failed for #{field}: #{value}")
end
end
def validate_email(email)
unless email =~ /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
raise ValidationError.new(:email, email, "Invalid email format")
end
email.downcase
end
begin
validate_email("not-an-email")
rescue ValidationError => e
puts "Field: #{e.field}, Value: #{e.value}, Message: #{e.message}"
end
Ruby's Exception Hierarchy
Understanding the exception class tree helps you rescue the right things. Here's the essential hierarchy:
Exception
βββ NoMemoryError
βββ ScriptError
β βββ LoadError
β βββ NotImplementedError
β βββ SyntaxError
βββ SecurityError
βββ SignalException
β βββ Interrupt
βββ StandardError # Default rescue target
β βββ ArgumentError
β βββ EncodingError
β βββ FiberError
β βββ IOError
β β βββ EOFError
β βββ IndexError
β βββ LocalJumpError
β βββ NameError
β β βββ NoMethodError
β βββ RangeError
β βββ RegexpError
β βββ RuntimeError
β βββ SystemCallError
β β βββ Errno::* (e.g., Errno::ENOENT)
β βββ ThreadError
β βββ TypeError
β βββ ZeroDivisionError
β βββ ... custom exceptions
βββ SystemExit
βββ SystemStackError
βββ fatal
Critically, a bare rescue (without specifying a class) catches only StandardError and its subclasses. It won't catch NoMemoryError, SystemExit, or SignalException. This is intentionalβthose exceptions typically indicate conditions you shouldn't try to recover from.
# Bare rescue catches StandardError
begin
raise TypeError, "type mismatch"
rescue
puts "Caught it!" # This will execute
end
# But not SystemExit or SignalException
begin
exit 1
rescue
puts "Won't see this"
end
# To catch absolutely everything (use with extreme caution)
begin
# dangerous code
rescue Exception => e
puts "Caught #{e.class}: #{e.message}"
# But you probably shouldn't catch NoMemoryError or SystemExit
end
Error Handling Patterns
Pattern 1: The Retry Pattern
When operations can fail transientlyβnetwork calls, database connections, external API requestsβretrying with backoff is invaluable. Ruby's retry keyword re-executes the entire begin block from the start.
require 'net/http'
def fetch_with_retry(url, max_attempts: 3, backoff: 1.5)
attempts = 0
begin
attempts += 1
puts "Attempt #{attempts} for #{url}..."
uri = URI(url)
response = Net::HTTP.get_response(uri)
if response.code.to_i >= 500
raise "Server error: #{response.code}"
end
puts "Success on attempt #{attempts}"
response.body
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED => e
if attempts < max_attempts
wait = backoff ** attempts
puts "Transient error: #{e.message}. Retrying in #{wait.round(2)}s..."
sleep(wait)
retry
else
puts "Exhausted retries after #{attempts} attempts"
raise # Re-raise the last exception
end
rescue => e
if attempts < max_attempts && e.message.include?("Server error")
wait = backoff ** attempts
puts "Server error, retrying in #{wait.round(2)}s..."
sleep(wait)
retry
else
raise
end
end
end
# Usage
begin
content = fetch_with_retry("https://api.example.com/data", max_attempts: 5)
puts "Got: #{content[0..100]}..."
rescue => e
puts "Final failure: #{e.message}"
end
The exponential backoff (backoff ** attempts) prevents overwhelming a struggling service while still recovering quickly when the issue resolves.
Pattern 2: The Fallback Pattern
When a primary operation fails, gracefully degrade to an alternative. This is perfect for cache lookups, feature availability, or multi-source data retrieval.
class UserProfileService
def initialize(primary_db:, cache:, fallback_api:)
@primary_db = primary_db
@cache = cache
@fallback_api = fallback_api
end
def fetch_profile(user_id)
# Try cache first (fastest)
profile = @cache.get("profile:#{user_id}")
return profile if profile
# Try primary database
begin
profile = @primary_db.query("SELECT * FROM users WHERE id = ?", user_id)
@cache.set("profile:#{user_id}", profile, ttl: 3600)
return profile
rescue DatabaseConnectionError => e
log_warning("Primary DB unavailable: #{e.message}, falling back")
end
# Fallback to secondary API
begin
profile = @fallback_api.get("/users/#{user_id}")
@cache.set("profile:#{user_id}", profile, ttl: 300)
return profile
rescue ApiError => e
log_error("Fallback API also failed: #{e.message}")
end
# Ultimate fallback: return a stub or default
log_error("All sources failed for user #{user_id}, returning default")
default_profile(user_id)
end
private
def default_profile(user_id)
{ id: user_id, name: "Unknown User", status: "unavailable" }
end
def log_warning(msg)
puts "[WARN] #{msg}"
end
def log_error(msg)
puts "[ERROR] #{msg}"
end
end
Pattern 3: The Result Object Pattern
Instead of exceptions, return objects that explicitly represent success or failure. This pattern, popular in functional programming and embraced by dry-rb libraries, makes error states explicit in the type system.
class Result
attr_reader :value, :error
def self.success(value)
new(value: value, success: true)
end
def self.failure(error)
new(error: error, success: false)
end
def success?
@success
end
def failure?
!@success
end
private
def initialize(value: nil, error: nil, success: false)
@value = value
@error = error
@success = success
end
end
class UserValidator
def validate(name:, email:, age:)
errors = []
errors << "Name cannot be empty" if name.to_s.strip.empty?
errors << "Email is invalid" unless email =~ /\A[^@\s]+@[^@\s]+\./
errors << "Age must be a positive number" unless age.is_a?(Integer) && age > 0
if errors.empty?
Result.success({ name: name.strip, email: email.downcase, age: age })
else
Result.failure(errors.join("; "))
end
end
end
# Usage without exceptions
validator = UserValidator.new
result = validator.validate(name: "Alice", email: "alice@example.com", age: 30)
if result.success?
puts "Valid user: #{result.value}"
else
puts "Validation errors: #{result.error}"
end
# Chaining with pattern matching (Ruby 3+)
case result
in { success: true, value: }
puts "Processing #{value[:name]}"
in { success: false, error: }
puts "Cannot proceed: #{error}"
end
The Result pattern shines when errors are expected and part of normal flowβlike validation, parsing, or business rule violations. It avoids the performance cost of exception handling for predictable failure paths.
Pattern 4: The Circuit Breaker Pattern
When a dependency repeatedly fails, stop calling it for a while to let it recover and to avoid wasting resources on guaranteed failures. This is critical in microservices and distributed systems.
class CircuitBreaker
State = Struct.new(:closed, :open, :half_open)
def initialize(failure_threshold: 3, recovery_timeout: 30)
@failure_threshold = failure_threshold
@recovery_timeout = recovery_timeout
@state = :closed
@failure_count = 0
@last_failure_time = nil
@mutex = Mutex.new
end
def call
@mutex.synchronize do
case @state
when :open
if Time.now - @last_failure_time >= @recovery_timeout
puts "Circuit breaker: open -> half-open"
@state = :half_open
else
raise CircuitOpenError, "Circuit is open, refusing call"
end
when :half_open
# Allow one trial call
when :closed
# Normal operation
end
end
begin
result = yield
on_success
result
rescue => e
on_failure
raise e
end
end
private
def on_success
@mutex.synchronize do
if @state == :half_open
puts "Circuit breaker: half-open -> closed (trial succeeded)"
@state = :closed
end
@failure_count = 0
end
end
def on_failure
@mutex.synchronize do
@failure_count += 1
@last_failure_time = Time.now
if @state == :half_open || (@state == :closed && @failure_count >= @failure_threshold)
puts "Circuit breaker: #{@state} -> open (#{@failure_count} failures)"
@state = :open
end
end
end
class CircuitOpenError < StandardError; end
end
# Usage with an unreliable service
breaker = CircuitBreaker.new(failure_threshold: 3, recovery_timeout: 10)
def unreliable_api_call
# Simulates a flaky external service
if rand < 0.7
raise "Service timeout"
else
"Success data"
end
end
10.times do |i|
begin
result = breaker.call { unreliable_api_call }
puts "Call #{i}: Got #{result}"
rescue CircuitBreaker::CircuitOpenError => e
puts "Call #{i}: Skipped - #{e.message}"
rescue => e
puts "Call #{i}: Failed - #{e.message}"
end
sleep 1
end
Pattern 5: Defensive Programming with Guard Clauses
Guard clauses prevent errors before they happen by validating inputs early and failing fast. Combined with custom exceptions, they create clear contracts at method boundaries.
class TransferService
class InsufficientFundsError < StandardError; end
class InvalidAccountError < StandardError; end
class TransferLimitExceededError < StandardError; end
DAILY_LIMIT = 10_000
def transfer(from:, to:, amount:)
# Guard clauses - fail fast with specific errors
raise ArgumentError, "Amount must be positive" unless amount.is_a?(Numeric) && amount > 0
raise InvalidAccountError, "Source account not found: #{from}" unless account_exists?(from)
raise InvalidAccountError, "Destination account not found: #{to}" unless account_exists?(to)
raise InvalidAccountError, "Cannot transfer to same account" if from == to
raise TransferLimitExceededError, "Amount #{amount} exceeds daily limit #{DAILY_LIMIT}" if amount > DAILY_LIMIT
raise InsufficientFundsError, "Account #{from} has insufficient balance" unless balance(from) >= amount
# If we reach here, all preconditions are met
execute_transfer(from, to, amount)
"Transfer of $#{amount} from #{from} to #{to} completed"
end
private
def account_exists?(account_id)
# Stub implementation
["acc_123", "acc_456"].include?(account_id)
end
def balance(account_id)
account_id == "acc_123" ? 5000 : 20000
end
def execute_transfer(from, to, amount)
puts "Moving #{amount} from #{from} to #{to}"
end
end
service = TransferService.new
begin
puts service.transfer(from: "acc_123", to: "acc_456", amount: 15000)
rescue TransferService::InsufficientFundsError => e
puts "Cannot complete: #{e.message}"
rescue TransferService::TransferLimitExceededError => e
puts "Limit hit: #{e.message}. Try smaller amount."
rescue TransferService::InvalidAccountError => e
puts "Account issue: #{e.message}"
rescue ArgumentError => e
puts "Invalid input: #{e.message}"
end
Pattern 6: Structured Logging and Context Enrichment
When exceptions occur, having rich context makes debugging dramatically faster. Attach relevant state to exceptions or log it before re-raising.
require 'json'
require 'time'
class ContextualError < StandardError
attr_reader :context, :timestamp
def initialize(message, context = {})
@context = context
@timestamp = Time.now.utc.iso8601(6)
super(message)
end
def to_hash
{
error_class: self.class.name,
message: message,
timestamp: @timestamp,
context: @context,
backtrace: backtrace&.first(10)
}
end
end
class OrderProcessor
class ProcessingError < ContextualError; end
def process(order_id, customer_id)
context = { order_id: order_id, customer_id: customer_id, step: nil }
begin
context[:step] = "inventory_check"
inventory = check_inventory(order_id)
context[:items_count] = inventory[:count]
context[:step] = "payment_processing"
payment = process_payment(customer_id, inventory[:total])
context[:payment_id] = payment[:id]
context[:step] = "fulfillment"
fulfill_order(order_id, payment[:id])
log_success(context)
rescue => e
enriched_error = ProcessingError.new(
"Order processing failed at step: #{context[:step]}",
context
)
enriched_error.set_backtrace(e.backtrace)
log_failure(enriched_error)
raise enriched_error
end
end
private
def check_inventory(order_id)
# Simulated inventory check
{ count: 3, total: 99.97 }
end
def process_payment(customer_id, amount)
# Could fail with PaymentError
raise "Payment gateway timeout" if rand < 0.3
{ id: "pay_#{rand(10000)}", amount: amount }
end
def fulfill_order(order_id, payment_id)
puts "Fulfilling order #{order_id} with payment #{payment_id}"
end
def log_success(context)
puts "[SUCCESS] #{context.to_json}"
end
def log_failure(error)
puts "[ERROR] #{error.to_hash.to_json}"
end
end
processor = OrderProcessor.new
begin
processor.process("order_42", "cust_7")
rescue ContextualError => e
puts "Failed with context: #{e.context}"
puts "Backtrace: #{e.backtrace.first(3).join("\n")}"
end
Advanced Techniques
The "Bouncer" Pattern: Centralized Error Handling
For web applications or service layers, route all errors through a central handler that transforms exceptions into appropriate responses. This keeps error mapping logic in one place.
class ErrorHandler
HANDLERS = {
ArgumentError => ->(e) { { status: 400, body: { error: "Bad Request", detail: e.message } } },
ActiveRecord::RecordNotFound => ->(e) { { status: 404, body: { error: "Not Found", detail: e.message } } },
AuthorizationError => ->(e) { { status: 403, body: { error: "Forbidden", detail: e.message } } },
PaymentGatewayError => ->(e) { { status: 502, body: { error: "Payment Failed", detail: "Service temporarily unavailable" } } },
StandardError => ->(e) { { status: 500, body: { error: "Internal Error", detail: "An unexpected error occurred" } } }
}
def self.handle(exception)
handler = HANDLERS.find { |klass, _| exception.is_a?(klass) }
handler ? handler.last.call(exception) : HANDLERS[StandardError].last.call(exception)
end
end
class AuthorizationError < StandardError; end
class PaymentGatewayError < StandardError; end
def api_endpoint
begin
# Application logic
user = find_user(params[:id])
authorize!(user)
process_payment(user)
rescue => e
response = ErrorHandler.handle(e)
render json: response[:body], status: response[:status]
end
end
Thread-Safe Error Handling
In multi-threaded contexts, exceptions in threads need special attention. Unhandled exceptions in threads can silently terminate them without affecting the main thread.
def parallel_process(items)
threads = []
errors = Concurrent::Array.new # Thread-safe array
items.each do |item|
threads << Thread.new do
begin
result = process_item(item)
Thread.current[:result] = result
rescue => e
errors << { item: item, error: e.message, thread: Thread.current.object_id }
end
end
end
threads.each(&:join)
if errors.any?
puts "Encountered #{errors.size} errors:"
errors.each { |err| puts " Item #{err[:item]}: #{err[:error]}" }
end
threads.map { |t| t[:result] }.compact
end
def process_item(item)
raise "Processing failed for #{item}" if rand < 0.2
"Processed: #{item}"
end
results = parallel_process([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
puts "Successful results: #{results}"
Best Practices for Error Handling in Ruby
1. Rescue Specific Exceptions
Never use bare rescue without a specific exception class unless you truly intend to catch all StandardError subclasses. Catching too broadly hides bugs and makes debugging harder.
# Bad - catches everything, even things you didn't anticipate
begin
process_data
rescue => e
# What actually went wrong? No idea.
log_error(e.message)
end
# Good - catches only what you expect and can handle
begin
process_data
rescue NetworkError => e
retry_or_fallback(e)
rescue ValidationError => e
return_error_to_user(e)
rescue DatabaseConnectionError => e
failover_to_replica(e)
end
2. Fail Fast with Guard Clauses
Validate inputs at the earliest possible point. Raise specific exceptions immediately when preconditions aren't met, rather than letting the code stumble into cryptic failures later.
def charge_credit_card(card:, amount:)
raise ArgumentError, "Card required" unless card
raise ArgumentError, "Amount must be positive" unless amount&.positive?
raise InvalidCardError, "Card is expired" if card.expired?
raise InsufficientFundsError if card.balance < amount
# Now safe to proceed
card.debit(amount)
end
3. Never Rescue Exception (Almost Never)
Rescuing Exception catches SystemExit, SignalException, and NoMemoryErrorβsignals that the process should terminate. Intercepting these can leave your application in an unrecoverable state.
# Dangerous - prevents Ctrl+C from working, swallows fatal errors
begin
loop { work }
rescue Exception => e
puts "Caught #{e.class}"
retry # Infinite loop, can't even interrupt with Ctrl+C
end
# Safe - only catch recoverable errors
begin
loop { work }
rescue StandardError => e
puts "Recoverable error: #{e.message}"
retry if should_retry?(e)
end
4. Add Context Before Re-raising
When you catch and re-throw, enrich the exception with information that was available at your level but not at higher levels.
def process_batch(batch_id)
batch = Batch.find(batch_id)
batch.items.each_with_index do |item, index|
begin
process_item(item)
rescue => e
# Add context before allowing it to propagate
raise ItemProcessingError.new(
"Failed processing item ##{index} in batch ##{batch_id}",
batch_id: batch_id,
item_index: index,
item_id: item.id
)
end
end
end
5. Separate Expected from Unexpected Errors
Use the Result pattern or custom exception subclasses for errors that are part of normal business logic. Reserve rescue for truly unexpected failures.
# Expected errors as Result objects
def parse_user_input(input)
return Result.failure("Email required") if input[:email].to_s.empty?
return Result.failure("Invalid email") unless valid_email?(input[:email])
Result.success(normalize(input))
end
# Unexpected errors as exceptions
def save_to_database(record)
database.insert(record)
rescue DatabaseConnectionError => e
# This shouldn't happen normally - alert on-call
alert_operations_team(e)
raise
end
6. Log Exception Details, Return Sanitized Messages
Users and API clients need helpful but safe error messages. Log the full stack trace and internal details for debugging, but return sanitized responses.
def api_create_user(params)
user = User.create!(params)
{ status: 201, user: user.to_json }
rescue ActiveRecord::RecordInvalid => e
# Log everything for debugging
Rails.logger.error("User creation failed: #{e.class} - #{e.message}")
Rails.logger.error(e.backtrace.first(5).join("\n"))
# Return safe, helpful message
{
status: 422,
error: "Validation failed",
details: user.errors.full_messages # Only shows validation errors, not internals
}
rescue => e
Rails.logger.error("Unexpected error: #{e.class} - #{e.message}\n#{e.backtrace&.first(10)&.join("\n")}")
# Never expose raw exception messages to clients
{
status: 500,
error: "Internal server error"
}
end
7. Use ensure for Resource Cleanup
Files, connections, locks, and other resources must be released regardless of success or failure. The ensure block is the reliable way to guarantee cleanup.
def with_temp_file(data)
tempfile = Tempfile.new('processing')
tempfile.write(data)
tempfile.rewind
begin
yield(tempfile)
ensure
tempfile.close
tempfile.unlink # Delete the temp file
puts "Temp file cleaned up"
end
end
with_temp_file("sensitive data") do |file|
raise "Processing error!" # Even if this happens, cleanup runs
puts file.read
end
8. Test Your Error Handling
Error handling code is codeβit needs tests. Write specs that trigger each exception path and verify the recovery behavior.
# RSpec example
RSpec.describe PaymentService do
describe "#charge" do
it "retries on transient network errors" do
service = PaymentService.new
call_count = 0
allow(Net::HTTP).to receive(:post) do
call_count += 1
raise Net::OpenTimeout if call_count < 3
double(code: "200", body: '{"status":"success"}')
end
result = service.charge(amount: 100, token: "tok_123")
expect(result).to eq("success")
expect(call_count).to eq(3)
end
it "raises after exhausting retries" do
service = PaymentService.new
allow(Net::HTTP).to receive(:post).and_raise(Net::OpenTimeout)
expect { service.charge(amount: 100, token: "tok_123") }
.to raise_error(PaymentService::PaymentError)
end
end
end
Conclusion
Error handling in Ruby is a rich discipline that extends far beyond basic begin/rescue blocks. By combining Ruby's exception hierarchy with patterns like retry-with-backoff, fallback chains, result objects, circuit breakers, and contextual error enrichment, you build applications that fail predictably and recover gracefully. The key principles are simple: rescue specifically, fail fast with guard clauses, never swallow fatal exceptions, add context when re-raising, sanitize output while logging internals, and always clean up resources in ensure blocks. When you treat error handling as a first-class design concern rather than an afterthought, your Ruby applications become resilient, debuggable, and production-ready. The patterns in this tutorial form a toolkitβchoose the ones that match your domain's failure modes, combine them thoughtfully, and your code will handle the unexpected with confidence.