← Back to DevBytes

Error Handling Patterns in Ruby

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:

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.

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles