Polyglot
Languages Ruby error handling
Ruby § error-handling

Error handling

Ruby admits exceptions as the conventional error-handling mechanism. Exceptions are first-class objects (instances of classes deriving from Exception); they are raised with raise and caught with begin/rescue/else/ensure/end. The convention is typed exceptions — domain-specific subclasses of StandardError admit rescue-by-type. The principal control-flow forms: begin/rescue (catch by class), retry (re-execute the begin block), ensure (run regardless), and raise (throw). Method-level rescue admits catching exceptions without an explicit begin block. The combination — typed exception classes, the StandardError hierarchy, the conventional rescue patterns, the ensure cleanup, the retry mechanism — is the substance of Ruby’s error model.

raise

The raise keyword throws an exception:

raise "something went wrong"                      # raises RuntimeError
raise StandardError, "details"                    # specific class
raise StandardError.new("details")                # explicit instance
raise StandardError, "details", caller            # with backtrace

# Re-raise:
raise                                             # re-raises the current exception

The convention: raise "msg" creates a RuntimeError (a subclass of StandardError); raise CustomError, "msg" creates an instance of the specified class.

begin/rescue

begin
  risky_operation
rescue StandardError => e
  puts "error: #{e.message}"
end

The form: begin ... rescue [Class => name] ... end. Multiple rescue clauses admit type-based dispatch:

begin
  result = process(input)
rescue ValidationError => e
  log_validation(e.field, e.message)
rescue NetworkError => e
  retry_later(e)
rescue StandardError => e
  log_unexpected(e)
end

The first matching rescue runs; the search proceeds top-to-bottom.

The Exception hierarchy

Ruby’s exception hierarchy:

Exception
├── NoMemoryError
├── ScriptError
│   ├── LoadError
│   ├── NotImplementedError
│   └── SyntaxError
├── SecurityError
├── SignalException
│   └── Interrupt
├── StandardError                                 # the conventional ancestor for "normal" errors
│   ├── ArgumentError
│   ├── EncodingError
│   ├── FiberError
│   ├── IOError
│   │   └── EOFError
│   ├── IndexError
│   │   ├── KeyError
│   │   └── StopIteration
│   ├── LocalJumpError
│   ├── NameError
│   │   └── NoMethodError
│   ├── RangeError
│   │   └── FloatDomainError
│   ├── RegexpError
│   ├── RuntimeError                             # the default for `raise "msg"`
│   │   └── FrozenError
│   ├── SystemCallError                          # Errno::*
│   ├── ThreadError
│   ├── TypeError
│   └── ZeroDivisionError
├── SystemExit
└── fatal

The principal structural rule: user code should rescue StandardError, not Exception.

Exception includes things like SignalException (Ctrl+C), SystemExit (exit keyword), and NoMemoryError. Catching them indiscriminately interferes with normal Ruby operation.

# Wrong (catches signals, exit, etc.):
begin
  risky
rescue Exception => e
  # ...
end

# Right (catches application errors):
begin
  risky
rescue => e                                       # rescue StandardError is the default
  # ...
end

# Equivalent:
begin
  risky
rescue StandardError => e
  # ...
end

The bare rescue (with no class) is equivalent to rescue StandardError.

ensure

The ensure clause runs whether or not an exception was raised:

file = File.open("data.txt")
begin
  process(file)
rescue => e
  log(e)
ensure
  file.close                                      # always closes
end

The mechanism admits substantial cleanup; the conventional alternative is File.open with a block, which handles the cleanup automatically:

File.open("data.txt") do |file|
  process(file)                                   # closes automatically
end

The block-form is the conventional Ruby idiom for resource management.

else

The else clause runs only if no exception was raised:

begin
  result = compute
rescue => e
  log(e)
else
  puts "computed: #{result}"                      # only on success
ensure
  cleanup
end

The form is rare in idiomatic Ruby; placing the success code inside the begin block (after the operation) is conventionally clearer.

retry

The retry keyword re-executes the begin block:

attempts = 0
begin
  attempts += 1
  result = fetch_data
rescue NetworkError => e
  if attempts < 3
    sleep 1
    retry                                         # try the begin block again
  else
    raise
  end
end

The mechanism admits substantial retry logic; the conventional discipline limits attempts to avoid infinite retries.

Method-level rescue

Methods admit rescue without an explicit begin block:

def safe_compute
  risky_operation
rescue NetworkError => e
  log_network_error(e)
  default_result
ensure
  cleanup
end

The form is conventional for methods where the entire body is the protected region.

Block-level rescue (for blocks)

Blocks admit rescue (since Ruby 2.5+):

[1, 2, 3].each do |n|
  process(n)
rescue => e
  log(e)
end

The mechanism admits substantial conciseness for per-iteration handling.

Custom exceptions

The conventional pattern is subclassing StandardError:

class ValidationError < StandardError
  attr_reader :field

  def initialize(field, message = nil)
    @field = field
    super(message || "validation error on #{field}")
  end
end

class NotFoundError < StandardError
  def initialize(id)
    super("not found: #{id}")
  end
end

# Usage:
raise ValidationError.new(:email, "invalid format")
raise NotFoundError.new(123)

# Catching by type:
begin
  process
rescue ValidationError => e
  puts "invalid #{e.field}: #{e.message}"
rescue NotFoundError => e
  puts e.message
end

The conventional discipline:

  • Subclass StandardError (not Exception).
  • Use specific classes — admits type-based dispatch.
  • Add structured fieldsfield, code, etc.
  • Use a meaningful message — for human display.

Module-namespaced exceptions

module API
  class Error < StandardError; end
  class TimeoutError < Error; end
  class ValidationError < Error; end
  class NotFoundError < Error; end
end

raise API::TimeoutError, "request timed out"

# Catch all API errors:
rescue API::Error => e
  # ...

The pattern admits substantial hierarchical organisation; conventional in libraries.

Re-raising

The raise (no argument) inside a rescue re-raises the current exception:

begin
  risky
rescue NetworkError => e
  log(e)
  raise                                           # re-raise
end

# Or wrap and re-raise:
rescue => e
  raise CustomError, "failed: #{e.message}"

The conventional form for “log and re-raise” is raise; for “wrap with context”, raise CustomError, "..." (loses the original cause unless explicitly preserved).

For cause preservation (Ruby 2.1+):

begin
  risky
rescue => e
  raise CustomError, "wrapped"                    # cause is automatically preserved
end

# Or explicitly:
raise CustomError, "wrapped", cause: e

The cause admits accessing the original exception:

begin
  begin
    raise "inner"
  rescue
    raise "outer"
  end
rescue => e
  puts e.message                                  # "outer"
  puts e.cause.message                            # "inner"
end

throw and catch

A non-local control flow mechanism — not exception handling:

result = catch(:found) do
  arr.each do |x|
    arr.each do |y|
      throw :found, [x, y] if x + y == target
    end
  end
  nil                                             # if not found
end

The throw/catch admits substantial early termination from nested loops; conventionally rare in modern Ruby.

The mechanism is not an exception — throw does not invoke rescue clauses.

Predicates and validation

def deposit(amount)
  raise ArgumentError, "amount must be positive" if amount <= 0
  raise ArgumentError, "amount must be a number" unless amount.is_a?(Numeric)

  @balance += amount
end

The conventional discipline raises ArgumentError for invalid arguments, TypeError for type mismatches, RangeError for out-of-range values.

Common patterns

Resource cleanup

def with_file(path)
  file = File.open(path)
  yield file
ensure
  file&.close
end

# But conventionally, use the block form:
File.open(path) { |f| process(f) }

Wrapping errors

def process(input)
  parse(input)
rescue JSON::ParserError => e
  raise InvalidInputError, "not valid JSON: #{e.message}"
end

The pattern preserves the cause; the wrapping admits substantial context.

Retry with backoff

def with_retries(max_attempts: 3, base_delay: 1)
  attempts = 0
  begin
    attempts += 1
    yield
  rescue NetworkError => e
    if attempts < max_attempts
      sleep(base_delay * (2 ** (attempts - 1)))
      retry
    else
      raise
    end
  end
end

result = with_retries { fetch_data }

Multiple rescue clauses

begin
  data = JSON.parse(File.read(path))
  Schema.validate!(data)
  process(data)
rescue JSON::ParserError => e
  log "invalid JSON in #{path}: #{e.message}"
rescue Schema::ValidationError => e
  log "invalid schema: #{e.errors}"
rescue Errno::ENOENT => e
  log "file not found: #{path}"
rescue => e
  log "unexpected: #{e.class}: #{e.message}"
  raise
end

Method-level rescue

def load_config(path)
  YAML.load_file(path)
rescue Errno::ENOENT
  default_config
rescue Psych::SyntaxError => e
  raise ConfigError, "invalid YAML: #{e.message}"
end

Custom error hierarchy

module Payment
  class Error < StandardError; end
  class CardDeclined < Error
    attr_reader :reason
    def initialize(reason)
      @reason = reason
      super("card declined: #{reason}")
    end
  end
  class InsufficientFunds < Error; end
  class NetworkError < Error; end
end

begin
  charge(card, amount)
rescue Payment::CardDeclined => e
  notify_user("declined: #{e.reason}")
rescue Payment::InsufficientFunds
  notify_user("insufficient funds")
rescue Payment::Error => e
  log "payment failed: #{e.message}"
end

Exception with structured data

class HttpError < StandardError
  attr_reader :status, :body, :headers

  def initialize(status:, body:, headers: {})
    @status = status
    @body = body
    @headers = headers
    super("HTTP #{status}: #{body[0, 100]}")
  end
end

raise HttpError.new(status: 404, body: '{"error": "not found"}')

Validation with collected errors

class ValidationError < StandardError
  attr_reader :errors

  def initialize(errors)
    @errors = errors
    super("validation failed: #{errors.length} error(s)")
  end
end

def validate!(user)
  errors = []
  errors << "name is required" if user.name.empty?
  errors << "age must be positive" if user.age && user.age < 0
  errors << "email is invalid" if user.email && !valid_email?(user.email)

  raise ValidationError.new(errors) if errors.any?
end

Result-style return (alternative to exceptions)

class Result
  def self.ok(value); Success.new(value); end
  def self.err(error); Failure.new(error); end
end

class Success < Result
  attr_reader :value
  def initialize(value); @value = value; end
  def ok?; true; end
end

class Failure < Result
  attr_reader :error
  def initialize(error); @error = error; end
  def ok?; false; end
end

def divide(a, b)
  return Result.err("division by zero") if b == 0
  Result.ok(a / b)
end

case divide(10, 2)
in Success(value:)
  puts "got #{value}"
in Failure(error:)
  puts "error: #{error}"
end

The pattern admits substantial type-explicit error handling; conventional in some Ruby code (particularly Rails service objects), though exceptions remain the conventional default.

Logging without re-raising

begin
  perform_optional_operation
rescue => e
  log_warning("optional operation failed: #{e.message}")
  # don't re-raise; continue
end

ensure for cleanup with potentially failing close

def with_resource
  resource = acquire
  yield resource
ensure
  begin
    resource&.close
  rescue => e
    log "cleanup failed: #{e.message}"
    # don't re-raise from ensure
  end
end

A note on rescue Exception

begin
  risky
rescue Exception => e                              # WRONG in almost all cases
  # ...
end

This catches:

  • SignalException — Ctrl+C interferes with normal interrupts.
  • SystemExitexit is intercepted.
  • NoMemoryError — typically unrecoverable.
  • SyntaxError — should not be caught at runtime.

The conventional discipline always uses rescue (defaulting to StandardError) or specific classes.

A note on the conventional discipline

The contemporary Ruby error-handling advice:

  • Use exceptions for exceptional conditions.
  • Subclass StandardError for custom exceptions.
  • Use specific exception classes — admits type-based dispatch.
  • Use bare rescue (catches StandardError); avoid rescue Exception.
  • Use multiple rescue clauses for type-based dispatch.
  • Use ensure for cleanup.
  • Use block forms (File.open { ... }) over manual ensure.
  • Use retry with attempt limits.
  • Use raise (no args) to re-raise the current exception.
  • Use cause to preserve original errors when wrapping.
  • Use raise ArgumentError for invalid arguments; TypeError for type mismatches.
  • Avoid throw/catch for exception-style flow.
  • Group exceptions by module for libraries.

The combination — first-class exception objects, the substantial Exception hierarchy, the conventional StandardError boundary, begin/rescue/else/ensure/end with optional retry, custom subclasses for domain errors, the cause chain — is the substance of Ruby’s error model. The discipline produces structured, traceable error handling with substantial flexibility.