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(notException). - Use specific classes — admits type-based dispatch.
- Add structured fields —
field,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.SystemExit—exitis 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
StandardErrorfor custom exceptions. - Use specific exception classes — admits type-based dispatch.
- Use bare
rescue(catchesStandardError); avoidrescue Exception. - Use multiple
rescueclauses for type-based dispatch. - Use
ensurefor cleanup. - Use block forms (
File.open { ... }) over manualensure. - Use
retrywith attempt limits. - Use
raise(no args) to re-raise the current exception. - Use
causeto preserve original errors when wrapping. - Use
raise ArgumentErrorfor invalid arguments;TypeErrorfor type mismatches. - Avoid
throw/catchfor 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.