Exceptions and Error Handling
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18). Ruby reports failure with exceptions: an object of
a class that descends from Exception is raised, the interpreter unwinds the call stack looking for a matching
rescue, and ensure clauses run on the way. The page covers the whole model: how to raise and rescue, the built-in
hierarchy and why a bare rescue only sees StandardError, how to design your own error classes, how to read the
messages and backtraces Ruby produces (including error_highlight and did_you_mean), cause chains, exceptions versus
control flow (catch/throw), Timeout and its caveats, how a program ends (exit, at_exit, abort), warnings,
exceptions in threads, testing error paths, and the RuboCop cops that police all of it.
It builds on Methods and Parameters,
Blocks, Procs and Lambdas and
Control Expressions (break, next, return and redo are
control flow, not exceptions), and uses the classes from
Classes and Objects and
Modules and Mixins. Later pages use it:
Threads and the GVL,
Testing with Minitest,
IO, Files and Directories,
Networking and HTTP,
Command-Line Programs,
Processes, Signals and Shell,
Style, Linting and Static Analysis and
Common Mistakes and Gotchas. For other languages'
approaches, see Python,
Java,
Kotlin,
C#,
Go and
Rust (errors are values in the last two), and
Swift.
Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using
the configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off). The
output shown is the real output; scripts that end with an uncaught exception show what Ruby printed to standard error.
The examples use the shelf gem (a small library catalogue, see
the shelf gem source) and are run with ruby -Ilib, so
require "shelf" finds it.
The model in one page
An exception is raised with raise, travels up the call stack, and is stopped by the first rescue clause whose class
list matches. Four clauses make up a handler, and they always appear in this order: the body, zero or more rescue
clauses, an optional else, and an optional ensure.
# frozen_string_literal: true
NETWORK_ERRORS = [IOError, SystemCallError].freeze
def handler_anatomy
# body: the code whose exceptions are covered
rescue ArgumentError, KeyError => e # a list of classes, optionally captured
warn e.message
rescue *NETWORK_ERRORS # a splatted array of classes or modules
retry
rescue StandardError => e # what a bare "rescue" means
warn "unexpected: #{e.class}"
raise # re-raise the exception being handled
else
# runs when the body raised nothing
ensure
warn "always runs"
end
Source: Exceptions: Rescued Exceptions
The statements that make up the whole error-handling vocabulary of the language:
| Construct | Meaning |
|---|---|
|
|
|
The handler. |
|
Matches when |
|
Binds the exception to a local variable. Also usable after a class list. |
|
Runs when the body finished without raising. Exceptions raised in it are not handled by the sibling |
|
Runs in every case (normal end, rescued exception, unhandled exception, |
|
Inside a |
|
The exception being handled and its backtrace; |
|
Non-local exit that is not an error: |
|
End the program. |
|
Non-fatal diagnostics on standard error, filterable by category. |
|
Interrupts a block after a time limit (library |
The flow of a handler, including retry and the clauses that always run:
The simplest complete example is a method with a method-level handler. Shelf::Isbn.new raises ArgumentError for a
malformed ISBN, the rescue clause turns that into a nil result, the else clause runs only for valid input, and the
ensure clause always reports. The method returns the value of its else clause on success and of its rescue clause
on failure, because the handler is an expression.
# frozen_string_literal: true
require "shelf"
# A method body is an implicit begin block: rescue, else and ensure go
# straight after the last statement.
def load_isbn(text)
isbn = Shelf::Isbn.new(text)
rescue ArgumentError => e
puts "rejected: #{e.message}"
nil
else
puts "accepted: #{isbn}"
isbn
ensure
puts "checked #{text.inspect}"
end
p load_isbn("978-0-306-40615-7")
p load_isbn("123")
accepted: 9780306406157
checked "978-0-306-40615-7"
#<data Shelf::Isbn digits="9780306406157">
rejected: invalid ISBN: 123
checked "123"
nil
Source: Exceptions: Rescued Exceptions
Note that the printed Shelf::Isbn uses the Ruby 3.4+ inspect format of Data objects, and that in both runs the
ensure output comes after the else or rescue output and before the caller prints the returned value: ensure runs
when control is about to leave the method.
Raising exceptions
Kernel#raise has two calling sequences, documented as raise(exception, message = exception.to_s, backtrace = nil,
cause: $!) and raise(message = nil, cause: $!). The first argument decides what is raised:
| Call | What is raised |
|---|---|
|
A |
|
A new |
|
A new |
|
That exception object, with its message and, if it already has one, its backtrace. |
|
A copy of the object made by |
|
Anything that answers |
|
Inside a |
|
A |
# frozen_string_literal: true
# An object that is not an exception but knows how to build one.
class Complaint
def exception(message = "complaint filed") = RuntimeError.new(message)
end
bad_isbn = ArgumentError.new("bad ISBN")
forms = {
"message only" => -> { raise "disk full" },
"class" => -> { raise ArgumentError },
"class, message" => -> { raise ArgumentError, "bad ISBN" },
"instance" => -> { raise bad_isbn },
"instance, message" => -> { raise bad_isbn, "other message" },
"exception method" => -> { raise Complaint.new, "via #exception" },
"no arguments" => -> { raise },
"not an exception" => -> { raise 42 },
"class, not an Exception" => -> { raise String }
}
forms.each do |name, form|
form.call
rescue StandardError => e
puts "#{name.ljust(24)} #{e.class}: #{e.message.inspect}"
end
message only RuntimeError: "disk full"
class ArgumentError: "ArgumentError"
class, message ArgumentError: "bad ISBN"
instance ArgumentError: "bad ISBN"
instance, message ArgumentError: "other message"
exception method RuntimeError: "via #exception"
no arguments RuntimeError: ""
not an exception TypeError: "exception class/object expected"
class, not an Exception TypeError: "exception class/object expected"
Source: Kernel#raise
fail is a synonym of raise. Some style guides reserve fail for "this method failed" and raise for re-raising
inside a rescue; RuboCop’s Style/SignalException enforces raise everywhere by default (only_raise), and so does
this page.
Two more arguments exist. The third positional argument sets the backtrace: an array of Thread::Backtrace::Location
(Ruby 3.4; the most consistent option, taken from caller_locations or from another exception’s
backtrace_locations), an array of strings, a single string, or nil. When it is strings, backtrace_locations is
nil. The keyword cause: sets the cause explicitly (see Wrapping and chaining); it cannot
be the only argument. Every other keyword is not passed on: it is collected into a positional Hash, which becomes the
message.
# frozen_string_literal: true
class ConfigError < StandardError
attr_reader :code
def initialize(message = "bad configuration", code: 1)
super(message)
@code = code
end
end
# Keywords other than cause: are NOT passed to the constructor; they become
# one positional Hash, which is used as the message.
begin
raise ConfigError, code: 5
rescue ConfigError => e
puts "#{e.inspect} code=#{e.code}"
end
begin
raise ConfigError.new("bad port", code: 5)
rescue ConfigError => e
puts "#{e.inspect} code=#{e.code}"
end
# An explicit backtrace (array of strings) replaces the real one.
begin
raise ConfigError, "from the DSL", %w[settings.rb:3 framework.rb:1]
rescue ConfigError => e
p e.backtrace
p e.backtrace_locations
end
# cause: alone is rejected.
begin
raise cause: RuntimeError.new("lonely")
rescue ArgumentError => e
puts e.message
end
# cause: nil suppresses the implicit cause.
begin
begin
Integer("x")
rescue ArgumentError
raise ConfigError, "wrapped", cause: nil
end
rescue ConfigError => e
p e.cause
end
#<ConfigError: {code: 5}> code=1
#<ConfigError: bad port> code=5
["settings.rb:3", "framework.rb:1"]
nil
only cause is given with no arguments
nil
Source: Kernel#raise
The last case of the first group is a common mistake: raise ConfigError, code: 5 does not call
ConfigError.new(code: 5). To pass keyword arguments to an exception’s constructor, build the object yourself and
raise it, raise ConfigError.new("bad port", code: 5). (RuboCop’s Style/RaiseArgs prefers the exploded form
raise Klass, "message" when there is a single message argument, and accepts the object form otherwise.)
Rules that follow from the mechanics above:
-
Raise a class and a message for the common case,
raise Shelf::NotFoundError, digits: the class is instantiated for you (Exception.exceptionisnew), the backtrace starts at theraiseline, and RuboCop has no objection. -
The message should say what was wrong and, if possible, which value:
invalid ISBN: 123rather thaninvalid. Messages conventionally start in lower case and have no trailing period, because Ruby appends the class name and often the location:file.rb:4:in 'Object#parse': invalid ISBN: 123 (ArgumentError). -
Only exceptions can be raised.
raisewith anything else is aTypeError, andExceptionitself (rather than a subclass) is almost never what you want to raise. -
The backtrace is set when the exception is raised, not when it is created.
ArgumentError.new.backtraceisnil.
Rescuing exceptions
What a rescue clause matches
A rescue clause names the classes or modules it handles. Ruby evaluates the list at the moment an exception reaches the
clause and tests each entry with ===. For a class or module that is "the exception is an instance of this class or of
a subclass" (Module#===). The consequences:
-
the clauses are tried in order and the first that matches handles the exception, and only once: an
ArgumentErrorthat matched an earlier clause is not offered to a laterrescue StandardError; -
put the specific classes first. A clause for a superclass above one for its subclass makes the second unreachable (RuboCop reports it as
Lint/ShadowedException); -
the list can be any expression that yields a class or module, including a splatted array, so a project can name its recoverable errors once;
-
an entry that is not a class or module raises
TypeError("class or module required for rescue clause") at the point the exception arrives, which hides the original error. RuboCop reports a literal such asrescue "boom"asLint/RescueType; -
a module with a custom
self.===works as a matcher, which is how a project can rescue "every retryable error" without an inheritance relationship.
# frozen_string_literal: true
require "shelf"
NETWORK_ERRORS = [IOError, SystemCallError].freeze
# rescue accepts a splatted list, so the set can be named and shared.
def probe(store)
store.call
rescue *NETWORK_ERRORS => e
"network: #{e.class}"
rescue Shelf::Error, KeyError => e
"domain: #{e.class}"
end
puts probe(-> { raise IOError })
puts probe(-> { File.read("missing.txt") })
puts probe(-> { raise Shelf::Error })
puts probe(-> { {}.fetch(:isbn) })
# rescue matches with ===, so any object that implements === can be a matcher.
module Retryable
def self.===(error) = error.respond_to?(:retryable?) && error.retryable?
end
FlakyError = Class.new(StandardError) do
def retryable? = true
end
begin
raise FlakyError, "try again"
rescue Retryable => e
puts "retryable: #{e.message}"
end
# Anything that is not a class or module is a TypeError, raised only when
# an exception actually reaches that clause.
begin
begin
raise "boom"
rescue "boom" # rubocop:disable Lint/RescueType
puts "never reached"
end
rescue TypeError => e
puts e.message
end
network: IOError
network: Errno::ENOENT
domain: Shelf::Error
domain: KeyError
retryable: try again
class or module required for rescue clause
Source: Exceptions: Rescued Exceptions
Capturing the exception with => name binds the object to a local variable that outlives the rescue clause. The
same object is also available as the global $!, and its backtrace as $@, both only inside the clause. Prefer the
local variable: $! is a thread-local global that changes when another exception is rescued in between.
Handlers without begin
The begin and end keywords can be dropped when the handler’s scope is already delimited. A method body, a do/end
block (since Ruby 2.5), a lambda written with do/end, and class and module bodies all accept rescue, else and
ensure directly. A brace block { ... } does not: a rescue in a brace block is a syntax error, so a block that needs a handler
is written with do/end.
The rescue modifier (expression rescue fallback) rescues StandardError only and gives no way to name the class or
see the exception. It hides every failure, including typos that raise NoMethodError, and RuboCop flags it as
Style/RescueModifier. The example keeps one occurrence on purpose.
# frozen_string_literal: true
# rescue inside a do/end block (Ruby 2.5) handles the failure per element.
totals = ["978-0-306-40615-7", "oops", "978-1-86197-876-9"].map do |text|
Integer(text.delete("-"))
rescue ArgumentError
0
end
p totals
# The rescue modifier catches StandardError only and hides the cause.
count = Integer("12 books") rescue 0 # rubocop:disable Style/RescueModifier
p count
# A lambda with do/end accepts rescue too.
parse = lambda do |text|
Float(text)
rescue ArgumentError, TypeError
Float::NAN
end
p parse.call("3.5"), parse.call("n/a"), parse.call(nil)
# begin/rescue is an expression; its value is the last expression evaluated.
value =
begin
Integer("x")
rescue ArgumentError
-1
else
0
ensure
puts "ensure ran"
end
p value
[9780306406157, 0, 9781861978769]
0
3.5
NaN
NaN
ensure ran
-1
A begin block is an expression. Its value is the value of the last statement of the body when nothing was raised and
there is no else; the value of the else clause when there is one; the value of the rescue clause when an exception
was handled. The ensure clause never changes the value (the last output above, -1, is the rescue result even
though else and ensure exist).
else
else is for code that should run only when the body succeeded and whose own failures must not be handled by the
sibling rescue clauses. Without it, the code would sit inside the body and an exception from it would be rescued by
accident:
# frozen_string_literal: true
def import(text)
value = Integer(text)
rescue ArgumentError
puts "rescue: #{text.inspect} is not a number"
0
else
# Runs only when the body raised nothing. An exception raised here is NOT
# seen by the rescue clauses above; it propagates to the caller.
raise ArgumentError, "#{value} is out of range" if value > 100
value
ensure
puts "ensure: done with #{text.inspect}"
end
p import("42")
p import("x")
begin
import("500")
rescue ArgumentError => e
puts "caller got: #{e.message}"
end
ensure: done with "42"
42
rescue: "x" is not a number
ensure: done with "x"
0
ensure: done with "500"
caller got: 500 is out of range
Source: Exceptions: Else Clause
ensure still runs after the exception from else leaves the method. Keep the body as small as possible: only the
statement that can raise the exception you handle belongs between begin and rescue.
ensure
ensure is for cleanup that must happen whatever happens: closing a file, releasing a lock, restoring a setting.
It runs after normal completion, after a rescued exception, when an exception is not handled at all, and when the body
leaves through return, break, next, throw or exit. Three rules prevent surprises, shown in the next example:
-
Its value is discarded; the handler’s value comes from the body,
elseorrescue. -
An explicit
return(orbreak,next) inensurecancels the exception in flight. The exception is silently lost. RuboCop reports it asLint/EnsureReturn. -
An exception raised by the
ensurecode replaces the original, which survives as itscause.
Most ensure clauses written by hand are unnecessary: the block forms of File.open, Dir.chdir, Mutex#synchronize
and Monitor#synchronize close or release in their own ensure.
# frozen_string_literal: true
require "English"
# 1. ensure runs, but its value is discarded.
def value_of_ensure
:body
ensure
puts "ensure ran"
end
p value_of_ensure
# 2. An explicit return inside ensure swallows the exception in flight.
def swallowed
raise "lost"
ensure
return :silently_returned # rubocop:disable Lint/EnsureReturn
end
p swallowed
# 3. An exception raised inside ensure replaces the original one,
# which survives as the cause.
def replaced
raise ArgumentError, "original"
ensure
raise IOError, "cleanup failed" if $ERROR_INFO
end
begin
replaced
rescue IOError => e
puts "#{e.class}: #{e.message} (cause: #{e.cause.inspect})"
end
# 4. Block forms close resources without a hand-written ensure.
File.open(__FILE__) do |file|
puts file.each_line.first.chomp
end
puts "closed after the block"
# 5. The same shape, written by hand.
file = File.open(__FILE__) # rubocop:disable Style/FileOpen
begin
file.readline
ensure
file.close
end
p file.closed?
ensure ran
:body
:silently_returned
IOError: cleanup failed (cause: #<ArgumentError: original>)
# frozen_string_literal: true
closed after the block
true
Source: Exceptions: Ensure Clause
retry
retry restarts the begin body from its first statement. It is valid only inside a rescue clause; anywhere else it is
a syntax error ("Invalid retry without rescue"). It is the building block for retrying a flaky operation. A retry loop
needs three things: a limit, so that the failure eventually propagates; a delay that grows (exponential backoff,
base * 2**(tries - 1), usually with random jitter so that many clients do not retry in lockstep); and a narrow class
list, so that only transient errors are retried. The sleeper is injected so that the example runs instantly.
# frozen_string_literal: true
# A store that fails twice before it answers.
class FlakyStore
def initialize(failures) = @failures = failures
def all
raise IOError, "connection reset" if (@failures -= 1) >= 0
%w[book-1 book-2]
end
end
def backoff(base, tries) = base * (2**(tries - 1))
def fetch_with_retry(store, attempts: 4, base_delay: 0.1, sleeper: method(:sleep))
tries = 0
begin
tries += 1
store.all
rescue IOError => e
raise if tries >= attempts
puts "attempt #{tries} failed (#{e.message}); retrying"
sleeper.call(backoff(base_delay, tries))
retry
end
end
delays = []
fake_sleep = ->(seconds) { delays << seconds }
p fetch_with_retry(FlakyStore.new(2), sleeper: fake_sleep)
begin
fetch_with_retry(FlakyStore.new(10), attempts: 3, sleeper: fake_sleep)
rescue IOError => e
puts "gave up: #{e.message}"
end
p delays
attempt 1 failed (connection reset); retrying
attempt 2 failed (connection reset); retrying
["book-1", "book-2"]
attempt 1 failed (connection reset); retrying
attempt 2 failed (connection reset); retrying
gave up: connection reset
[0.1, 0.2, 0.1, 0.2]
Source: Exceptions: Retrying
$ cat bad_retry.rb
def fetch
retry
end
$ ruby -c bad_retry.rb
bad_retry.rb:2: syntax error found (SyntaxError)
1 | def fetch
> 2 | retry
| ^~~~~~ Invalid retry without rescue
3 | end
Source: Exceptions: Retrying
To retry one iteration of a loop body (not a begin block), use redo, described in
Control Expressions. Retry only operations that are safe to
repeat (idempotent), and never retry a programming error such as NoMethodError: it will fail the same way every time.
Re-raising
A handler often needs to act and let the failure continue: log it, release something, add context. There are four ways to continue, and they differ in which object and which backtrace the caller sees:
| Form | Result |
|---|---|
|
The same object and the same backtrace. The right default for "log and pass on". |
|
The same object; because it already has a backtrace, that is kept too. |
|
A copy made by |
|
A new exception, with the handled one as its |
# frozen_string_literal: true
def load_config = raise(IOError, "config unreadable")
def attempt
load_config
rescue IOError => e
yield e
end
# 1. Bare raise: the same object, the same backtrace.
caught = nil
begin
attempt do |e|
caught = e
raise
end
rescue IOError => e
puts "bare raise: same object? #{e.equal?(caught)}, same backtrace? #{e.backtrace == caught.backtrace}"
end
# 2. raise e: also the same object, and the original backtrace is kept.
begin
attempt do |e|
caught = e
raise e
end
rescue IOError => e
puts "raise e: same object? #{e.equal?(caught)}, same backtrace? #{e.backtrace == caught.backtrace}"
end
# 3. raise e, "new message": a copy (via Exception#exception) with the new message.
begin
attempt do |e|
caught = e
raise e, "config unreadable (retried twice)"
end
rescue IOError => e
puts "raise e, message: same object? #{e.equal?(caught)}, message #{e.message.inspect}"
end
# 4. A new class: a new exception whose cause is the one being handled.
begin
attempt do |_e|
raise ArgumentError, "invalid configuration"
end
rescue ArgumentError => e
puts "new class: cause #{e.cause.inspect}"
end
bare raise: same object? true, same backtrace? true
raise e: same object? true, same backtrace? true
raise e, message: same object? false, message "config unreadable (retried twice)"
new class: cause #<IOError: config unreadable>
Source: Kernel#raise
Do not log and re-raise at every level of a call chain: the same failure then appears once per level in the log. Handle an exception in one place, usually at the edge of the program (see Handling errors at the edge of a program), and let it pass through the layers in between.
The exception hierarchy
All exceptions descend from Exception. The branch matters because rescue selects by class and a bare rescue
selects StandardError.
The documented hierarchy is in Exception (the list there omits NoMatchingPatternError, which exists). This program
prints the tree that the running interpreter actually has, using Class#subclasses (Ruby 3.1), before any library adds
its own classes:
# frozen_string_literal: true
# Walks the exception classes that exist before any library is loaded.
def print_tree(klass, depth = 0)
children = klass.subclasses.select { |c| c.name && !c.name.include?("::") }
puts "#{' ' * depth}#{klass}"
children.sort_by(&:name).each { |child| print_tree(child, depth + 1) }
end
print_tree(Exception)
puts
puts "Errno children: #{Errno.constants.size}, e.g. #{Errno::ENOENT.ancestors.take(3).join(' < ')}"
Exception
NoMemoryError
ScriptError
LoadError
NotImplementedError
SyntaxError
SecurityError
SignalException
Interrupt
StandardError
ArgumentError
UncaughtThrowError
EncodingError
FiberError
IOError
EOFError
IndexError
KeyError
StopIteration
ClosedQueueError
LocalJumpError
NameError
NoMethodError
NoMatchingPatternError
NoMatchingPatternKeyError
RangeError
FloatDomainError
RegexpError
RuntimeError
FrozenError
SystemCallError
ThreadError
TypeError
ZeroDivisionError
SystemExit
SystemStackError
fatal
Errno children: 158, e.g. Errno::ENOENT < SystemCallError < StandardError
Source: Class#subclasses
The branches have different meanings:
-
StandardErroris the branch for errors a program may reasonably handle: bad arguments, missing keys, failed I/O, type errors. Application and library errors belong here, andrescue => ehandles exactly this branch. -
ScriptError(LoadError,NotImplementedError,SyntaxError) describes a problem with the program itself, for example a missing file passed torequire. It is outsideStandardErroron purpose, so that a generic handler does not hide a broken installation. -
SignalExceptionand its subclassInterrupt(Ctrl-C) represent a signal delivered to the process. -
SystemExitis howexitworks: it is an exception, so thatensureclauses run, and a handler that catches it stops the program from exiting. -
NoMemoryError,SystemStackError(infinite recursion) andSecurityErrordescribe a process that cannot continue normally. The hidden classfatalis raised by the interpreter for situations such as a deadlock (every thread blocked); it cannot be rescued by name.
Why a bare rescue is StandardError and why never to rescue Exception
rescue without a class and rescue => e mean rescue StandardError. That is the line between "something my code
can deal with" and "stop the world", and it is deliberate. rescue Exception crosses it:
-
it catches
SystemExit, soexitstops working, andInterrupt, so Ctrl-C no longer interrupts the program; -
it catches
NoMemoryError,SystemStackErrorandScriptError, none of which the handler can repair; -
it catches
Timeout::ExitException, the exception used byTimeout.timeout, and so breaks timeouts.
The next example shows which handler sees which exception, and that a rescue of SystemExit turns exit into an
ordinary exception.
# frozen_string_literal: true
# Which handler sees which exception?
def classify(error)
raise error
rescue => e # rubocop:disable Style/RescueStandardError
"bare rescue catches #{e.class}"
rescue ScriptError, SignalException, SystemExit, NoMemoryError, SystemStackError => e
"escapes bare rescue: #{e.class}"
end
[
ZeroDivisionError.new, KeyError.new, Errno::ENOENT.new,
NotImplementedError.new, LoadError.new,
Interrupt.new, SystemExit.new,
NoMemoryError.new, SystemStackError.new
].each { |error| puts classify(error) }
# exit is an exception: a broad rescue would swallow it.
begin
exit 2
rescue SystemExit => e
puts "exit became #{e.class}, status #{e.status}"
end
bare rescue catches ZeroDivisionError
bare rescue catches KeyError
bare rescue catches Errno::ENOENT
escapes bare rescue: NotImplementedError
escapes bare rescue: LoadError
escapes bare rescue: Interrupt
escapes bare rescue: SystemExit
escapes bare rescue: NoMemoryError
escapes bare rescue: SystemStackError
exit became SystemExit, status 2
Source: StandardError
Source: ScriptError, SystemExit
The legitimate uses of rescue Exception are narrow: a top-level handler that records a fatal failure and then
re-raises with a bare raise, or a thread or fiber supervisor that must observe every way its child ended. Even then,
ensure is usually what you want. RuboCop’s Lint/RescueException flags the rest, and Style/RescueStandardError
(default explicit) asks you to write rescue StandardError instead of a bare rescue so that the choice is visible;
the examples here follow that cop except where a bare rescue is the point.
The classes you will meet
The table lists the exceptions raised most often by the core library, the method that raises each one, and the message Ruby 4.0 produces. The program after the table triggers each of them and prints the real class and message.
| Class | Typical cause | Useful accessors |
|---|---|---|
|
A method that does not exist, most often on |
|
|
An unknown constant, local variable or method name. |
|
|
Wrong number of arguments, a missing or unknown keyword, an invalid value ( |
none |
|
A value of the wrong type ( |
none |
|
Integer division or modulo by zero. |
none |
|
|
|
|
|
none |
|
|
|
|
Modifying a frozen object, here the frozen |
|
|
A number outside the accepted range, such as |
none |
|
A failed system call; a subclass of |
|
|
Reading from a closed stream; reading past the end of a stream. |
none |
|
An abstract method, or a platform feature that is missing. A |
none |
|
A |
|
|
Resuming a finished fiber; locking a mutex twice; pushing to a closed queue. |
none |
|
An invalid regular expression. |
none |
|
A character that has no representation in the target encoding. |
|
|
A |
|
|
A |
|
|
Unbounded recursion. |
none |
|
A |
none |
# frozen_string_literal: true
require "stringio"
require "shelf"
class Abstract
def area = raise(NotImplementedError, "#{self.class} must implement #{__method__}")
end
def recurse(depth) = recurse(depth + 1)
def callback = proc { return 1 }
dead_fiber = Fiber.new { :done }.tap(&:resume)
mutex = Thread::Mutex.new
closed = Thread::Queue.new.tap(&:close)
samples = {
"NoMethodError" => -> { nil.upcase },
"NameError (constant)" => -> { Object.const_get(:Missing) },
"NameError (local)" => -> { undefined_name },
"ArgumentError (keywords)" => -> { Shelf::Book.new },
"ArgumentError (value)" => -> { Shelf::Isbn.new("123") },
"ArgumentError (compare)" => -> { [3, "a"].sort },
"ArgumentError (lambda)" => -> { ->(x) { x }.call },
"TypeError (coerce)" => -> { 1 + nil },
"TypeError (implicit)" => -> { [1] + 1 },
"ZeroDivisionError" => -> { 1 / 0 },
"KeyError" => -> { { isbn: 1 }.fetch(:title) },
"IndexError" => -> { [1, 2].fetch(5) },
"FrozenError" => -> { Shelf::Loan::STATES << :lost },
"StopIteration" => -> { [].each.next },
"RangeError" => -> { 300.chr },
"FloatDomainError" => -> { Float::NAN.to_i },
"Math::DomainError" => -> { Math.sqrt(-1) },
"Errno::ENOENT" => -> { File.read("nope.txt") },
"IOError" => -> { File.open(__FILE__, &:itself).read },
"EOFError" => -> { StringIO.new.readline },
"NotImplementedError" => -> { Abstract.new.area },
"LocalJumpError" => -> { callback.call },
"FiberError" => -> { dead_fiber.resume },
"ThreadError" => -> { mutex.lock.lock },
"ClosedQueueError" => -> { closed.push(1) },
"RegexpError" => -> { Regexp.new("(") },
"Encoding::UndefinedConversionError" => -> { "é".encode("US-ASCII") },
"UncaughtThrowError" => -> { throw :nowhere },
"NoMatchingPatternError" => -> { Integer("7") => String },
"SystemStackError" => -> { recurse(0) }
}
samples.each do |label, sample|
sample.call
rescue StandardError, SystemStackError => e
puts "#{label}: #{e.class}: #{e.message.lines.first.strip}"
end
NoMethodError: NoMethodError: undefined method 'upcase' for nil
NameError (constant): NameError: uninitialized constant Missing
NameError (local): NameError: undefined local variable or method 'undefined_name' for main
ArgumentError (keywords): ArgumentError: missing keywords: :isbn, :title, :author
ArgumentError (value): ArgumentError: invalid ISBN: 123
ArgumentError (compare): ArgumentError: comparison of Integer with String failed
ArgumentError (lambda): ArgumentError: wrong number of arguments (given 0, expected 1)
TypeError (coerce): TypeError: nil can't be coerced into Integer
TypeError (implicit): TypeError: no implicit conversion of Integer into Array
ZeroDivisionError: ZeroDivisionError: divided by 0
KeyError: KeyError: key not found: :title
IndexError: IndexError: index 5 outside of array bounds: -2...2
FrozenError: FrozenError: can't modify frozen Array: [:available, :lent, :overdue, :returned]
StopIteration: StopIteration: iteration reached an end
RangeError: RangeError: 300 out of char range
FloatDomainError: FloatDomainError: NaN
Math::DomainError: Math::DomainError: Numerical argument is out of domain - sqrt
Errno::ENOENT: Errno::ENOENT: No such file or directory @ rb_sysopen - nope.txt
IOError: IOError: closed stream
EOFError: EOFError: end of file reached
builtin_errors.rb:7:in 'Abstract#area': Abstract must implement area (NotImplementedError)
from builtin_errors.rb:39:in 'block in <main>'
from builtin_errors.rb:52:in 'block in <main>'
from builtin_errors.rb:51:in 'Hash#each'
from builtin_errors.rb:51:in '<main>'
Source: NameError, KeyError, FrozenError, StopIteration, Errno, NoMatchingPatternKeyError
Two details are visible in the output. The message of NoMethodError names the receiver’s class (for an instance of
Shelf::Isbn, Ruby 3.3) or the value itself for nil, true, false and classes (for nil), and the quote around the
method name is a plain apostrophe (3.4); examples written for older versions show undefined method `x' for nil:NilClass.
And the ArgumentError for a missing keyword of a Data class lists every missing name at once.
System errors and errno
Failed operating-system calls raise a subclass of SystemCallError, one Errno:: class per error number
(Errno::ENOENT, Errno::EACCES, Errno::EEXIST, Errno::EPIPE, Errno::ECONNREFUSED and so on). Each class has an Errno
constant with the number, instances answer errno, and SystemCallError.new(number) builds the matching subclass.
Rescue the specific class for the case you can repair (a missing file) and the superclass for the rest.
# frozen_string_literal: true
begin
File.read("config.yml")
rescue Errno::EACCES, Errno::ENOENT => e
puts "#{e.class} errno=#{e.errno}: #{e.message}"
end
# Errno::* classes are subclasses of SystemCallError, one per errno value.
p Errno::ENOENT::Errno
p Errno::ENOENT.ancestors.take(3)
p Errno::ENOENT.new("config.yml").message
p SystemCallError.new(2).class
p SystemCallError.new("custom context", 13).message
Errno::ENOENT errno=2: No such file or directory @ rb_sysopen - config.yml
2
[Errno::ENOENT, SystemCallError, StandardError]
"No such file or directory - config.yml"
Errno::ENOENT
"Permission denied - custom context"
Source: Errno
Source: SystemCallError
Custom error classes
A library or application defines its own errors for two reasons: callers can rescue exactly the failures of that library, and the class name documents the failure. The conventions that Ruby code follows:
-
One root class per library, a subclass of
StandardError:Shelf::Errorhere,JSON::JSONError,Gem::LoadError. Callers who want "anything from this library" rescue the root; the library never raises a bareRuntimeErrorfor its own failures. -
A hierarchy that mirrors how callers will react.
Shelf::AlreadyLentError < Shelf::LoanError < Shelf::Errorlets a caller handle every loan problem together or one kind specifically. -
Names end in
Errorand describe the condition (NotFoundError), not the place it was detected. -
Subclass
StandardError(or a more specific built-in such asArgumentErrorwhen the condition really is a bad argument), neverException. A subclass ofExceptionescapes every ordinary handler. -
Carry the data a handler needs as readers (
isbn,book), not only inside the message text. Callers should not have to parse a message. -
Call
superwith the message, so thatmessage,inspect,full_messageand the uncaught-exception printer work without further code. Give the constructor a default message so thatraise Shelf::NotFoundError, isbnreads well. -
Do not override
message; overrideto_sif you must change the text (see below).
# frozen_string_literal: true
require "shelf"
module Shelf
# Shelf::Error (defined in lib/shelf.rb) is the root every caller can rescue.
class NotFoundError < Error
attr_reader :isbn
def initialize(isbn, message = "no book with ISBN #{isbn}")
@isbn = isbn
super(message)
end
end
class LoanError < Error; end
class AlreadyLentError < LoanError
attr_reader :book
def initialize(book)
@book = book
super("#{book.title} is already lent")
end
end
end
author = Shelf::Author.new("A. Writer")
isbn = Shelf::Isbn.new("978-0-306-40615-7")
book = Shelf::Book.new(isbn, "Ruby Basics", author)
lent = { isbn.digits => book }
def lend(lent, digits)
book = lent.fetch(digits) { raise Shelf::NotFoundError, digits }
raise Shelf::AlreadyLentError, book if book.title.start_with?("Ruby")
book
end
%w[9780306406157 0000000000000].each do |digits|
lend(lent, digits)
rescue Shelf::LoanError => e
puts "loan problem: #{e.message} by #{e.book.author.name}"
rescue Shelf::Error => e
puts "library problem: #{e.message} [#{e.class}]"
end
p Shelf::AlreadyLentError.ancestors.take(4)
p Shelf::NotFoundError.new("123").message
loan problem: Ruby Basics is already lent by A. Writer
library problem: no book with ISBN 0000000000000 [Shelf::NotFoundError]
[Shelf::AlreadyLentError, Shelf::LoanError, Shelf::Error, StandardError]
"no book with ISBN 123"
Source: Exception
A caller that wants to react to loan problems rescues Shelf::LoanError; the second rescue clause shows a
library-wide fallback that still sees NotFoundError. A single rescue Shelf::Error is the equivalent of the
"errors of this gem" boundary that a service object or command-line program needs.
message, to_s and inspect
Exception#message calls to_s, inspect is #<Class: to_s>, and detailed_message is message plus the class name.
Overriding to_s therefore changes all three, while overriding message changes only message and
detailed_message. Prefer to_s, or pass the final text to super.
# frozen_string_literal: true
# Overriding #to_s changes message, inspect and detailed_message together.
class ByToS < StandardError
def to_s = "from to_s"
end
# Overriding #message changes only message and detailed_message.
ByMessage = Class.new(StandardError) do
def message = "from message"
end
[ByToS, ByMessage].each do |klass|
error = klass.new("given to new")
puts klass
puts " to_s: #{error.to_s.inspect}"
puts " message: #{error.message.inspect}"
puts " inspect: #{error.inspect}"
puts " detailed_message: #{error.detailed_message(highlight: false).inspect}"
end
ByToS
to_s: "from to_s"
message: "from to_s"
inspect: #<ByToS: from to_s>
detailed_message: "from to_s (ByToS)"
ByMessage
to_s: "given to new"
message: "from message"
inspect: #<ByMessage: given to new>
detailed_message: "from message (ByMessage)"
Source: Exception#message
Generated error classes and richer messages
Error classes can be built with Class.new(parent), which is useful when many near-identical classes are needed. The
class gets its name when it is assigned to a constant (const_set). The same example overrides detailed_message
(Ruby 3.2) to list several problems under one error. The override must accept and forward keyword arguments, because Ruby
and its gems pass highlight:, did_you_mean:, error_highlight: and syntax_suggest::
# frozen_string_literal: true
require "shelf"
module Shelf
# One subclass per missing field, generated instead of written three times.
%w[Title Author Isbn].each do |field|
const_set(
"Missing#{field}Error",
Class.new(Error) do
define_method(:initialize) do |message = "#{field.downcase} is required"|
super(message)
end
end
)
end
# An error that carries a list of problems and prints them.
class ValidationError < Error
attr_reader :problems
def initialize(problems)
@problems = problems.freeze
super("#{problems.size} problems found")
end
# Keep the signature open: callers pass highlight:, did_you_mean: ...
def detailed_message(highlight: false, **)
[super, *problems.map { |problem| " - #{problem}" }].join("\n")
end
end
end
p Shelf::MissingTitleError.new
p Shelf::MissingIsbnError.new("ISBN missing").message
p Shelf::MissingAuthorError.ancestors.first(3)
begin
raise Shelf::ValidationError, ["title is blank", "ISBN has 3 digits"]
rescue Shelf::Error => e
puts e.message
puts e.detailed_message
puts e.full_message(highlight: false)
end
#<Shelf::MissingTitleError: title is required>
"ISBN missing"
[Shelf::MissingAuthorError, Shelf::Error, StandardError]
2 problems found
2 problems found (Shelf::ValidationError)
- title is blank
- ISBN has 3 digits
error_details.rb:39:in '<main>': 2 problems found (Shelf::ValidationError)
- title is blank
- ISBN has 3 digits
Source: Exception#detailed_message
Abstract methods: do not use NotImplementedError for them
Older code and tutorials signal "subclasses must implement this" with raise NotImplementedError. The name suggests it,
but NotImplementedError is a ScriptError and is reserved for "this platform does not support the call" (it is what
fork raises on Windows). A bare rescue, and so most test helpers and application error handlers, does not see it.
Raise a NoMethodError subclass, or a project error, for abstract methods:
# frozen_string_literal: true
module Shelf
# An abstract store. Subclasses must implement #all and #add.
class AbstractStore
def all = raise(NotImplementedError, "#{self.class} must implement #{__method__}")
def add(_book) = raise(NoMethodError, "#{self.class}#add is not implemented")
end
end
store = Shelf::AbstractStore.new
# NotImplementedError is a ScriptError, so a rescue of StandardError (what a
# bare rescue means) does not see it.
begin
begin
store.all
rescue StandardError => e
puts "bare rescue caught #{e.class}"
end
rescue NotImplementedError => e
puts "only an explicit rescue caught #{e.class} (a #{e.class.superclass})"
end
# A StandardError subclass is rescued by ordinary handlers.
begin
store.add(nil)
rescue StandardError => e
puts "bare rescue caught #{e.class}: #{e.message}"
end
only an explicit rescue caught NotImplementedError (a ScriptError)
bare rescue caught NoMethodError: Shelf::AbstractStore#add is not implemented
Source: NotImplementedError
Source: ScriptError
Messages, backtraces and diagnostics
An exception carries up to four pieces of information: its class, a message, a backtrace and a cause. Exception has one
accessor or formatter for each:
| Method | Returns |
|---|---|
|
The message string given to |
|
|
|
The message plus the class name, plus anything a gem adds (the |
|
The complete report: location, detailed message, backtrace and the cause chain. |
|
An array of strings, or |
|
The same frames as |
|
Replaces the backtrace. Accepts an array of |
|
The exception that was being handled when this one was raised (Ruby 2.1), or |
|
|
|
|
Pass highlight: explicitly whenever the text is not going straight to a terminal (a log file, a test assertion, an
HTML page): with highlight: true the string contains ANSI escape sequences for bold and underline.
# frozen_string_literal: true
def parse(text)
raise ArgumentError, "invalid ISBN: #{text}" unless text.length == 13
text
end
def import(text) = parse(text)
begin
import("978")
rescue ArgumentError => e
puts "message: #{e.message.inspect}"
puts "to_s: #{e.to_s.inspect}"
puts "inspect: #{e.inspect}"
puts "detailed_message: #{e.detailed_message(highlight: false).inspect}"
puts "highlighted: #{e.detailed_message(highlight: true).inspect}"
puts "backtrace (strings):"
e.backtrace.each { puts " #{it}" }
loc = e.backtrace_locations.first
puts "first location: #{loc.path}:#{loc.lineno} label=#{loc.label.inspect} base_label=#{loc.base_label.inspect}"
puts "full_message:"
puts e.full_message(highlight: false)
puts "full_message (bottom first):"
puts e.full_message(highlight: false, order: :bottom)
puts "to_tty?: #{Exception.to_tty?}"
end
multi = RuntimeError.new("line one\nline two")
puts multi.inspect
puts RuntimeError.new.inspect
puts RuntimeError.new("").inspect
p RuntimeError.new(:a_symbol).message
message: "invalid ISBN: 978"
to_s: "invalid ISBN: 978"
inspect: #<ArgumentError: invalid ISBN: 978>
detailed_message: "invalid ISBN: 978 (ArgumentError)\n\n raise ArgumentError, \"invalid ISBN: \#{text}\" unless text.length == 13\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"
highlighted: "\e[1minvalid ISBN: 978 (\e[1;4mArgumentError\e[m\e[1m)\e[m\n\n\e[1m raise ArgumentError, \"invalid ISBN: \#{text}\" unless text.length == 13\e[m\n\e[1m ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\e[m"
backtrace (strings):
messages.rb:4:in 'Object#parse'
messages.rb:9:in 'Object#import'
messages.rb:12:in '<main>'
first location: messages.rb:4 label="Object#parse" base_label="parse"
full_message:
messages.rb:4:in 'Object#parse': invalid ISBN: 978 (ArgumentError)
raise ArgumentError, "invalid ISBN: #{text}" unless text.length == 13
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
from messages.rb:9:in 'Object#import'
from messages.rb:12:in '<main>'
full_message (bottom first):
Traceback (most recent call last):
2: from messages.rb:12:in '<main>'
1: from messages.rb:9:in 'Object#import'
messages.rb:4:in 'Object#parse': invalid ISBN: 978 (ArgumentError)
raise ArgumentError, "invalid ISBN: #{text}" unless text.length == 13
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
to_tty?: false
#<RuntimeError:"line one\nline two">
#<RuntimeError: RuntimeError>
RuntimeError
"a_symbol"
Source: Exception#full_message
The frame labels in the backtrace changed in Ruby 3.4: the opening quote is an apostrophe (it was a backtick) and a method
label includes its class when the class has a permanent name, 'Object#parse' and 'Shelf::Isbn.new' instead of
parse and new. Code that parses backtrace strings with a regular expression has to accept both forms; code that reads
Location#path, lineno and base_label is unaffected. Since Ruby 3.4 the frames of internal rescue and ensure
sections no longer appear, and since 4.0 methods implemented in Ruby inside the core library (such as Integer() or
Array#fetch_values) are reported as if they were native, with the caller’s file and line.
error_highlight and did_you_mean
Two default gems improve the messages of uncaught errors, and of any exception whose detailed_message is printed.
error_highlight (Ruby 3.1) reads the source file and underlines the part of the line that failed: the method name in
NoMethodError, the constant in NameError, the offending operand in TypeError and ArgumentError. Since Ruby 4.0,
an ArgumentError about the number of arguments shows two snippets, the call site (caller) and the method definition
(callee). did_you_mean adds suggestions for misspelled method names, constants, variables, hash keys (KeyError) and
require paths. Both work through detailed_message, which is why an overriding method must forward its keywords.
error_highlight needs the source file of the failing line, so code passed with -e gets the plain message.
# frozen_string_literal: true
require "shelf"
isbn = Shelf::Isbn.new("978-0-306-40615-7")
begin
isbn.digit.upcase
rescue NoMethodError => e
puts e.detailed_message(highlight: false)
end
catalog = Shelf::Catalog.new
begin
puts catalog.first.author.name
rescue NoMethodError => e
puts e.detailed_message(highlight: false)
end
def lend(book, days) = "#{book} for #{days} days"
begin
lend("Ruby Basics")
rescue ArgumentError => e
puts e.detailed_message(highlight: false)
end
puts Shelf::Catalg
undefined method 'digit' for an instance of Shelf::Isbn (NoMethodError)
isbn.digit.upcase
^^^^^^
Did you mean? digits
undefined method 'author' for nil (NoMethodError)
puts catalog.first.author.name
^^^^^^^
wrong number of arguments (given 1, expected 2) (ArgumentError)
caller: highlight_demo.rb:23
| lend("Ruby Basics")
^^^^
callee: highlight_demo.rb:20
| def lend(book, days) = "#{book} for #{days} days"
^^^^
highlight_demo.rb:28:in '<main>': uninitialized constant Shelf::Catalg (NameError)
puts Shelf::Catalg
^^^^^^^^
Did you mean? Shelf::Catalog
Source: ErrorHighlight
Source: DidYouMean
They can be switched off for a run with --disable-error_highlight and --disable-did_you_mean (--disable-gems removes both as well). With both flags off, the final uncaught
error prints just the message and the backtrace.
Other command-line options that help with long failures are --backtrace-limit=N, which truncates the printed backtrace
(Ruby 3.0, Thread::Backtrace.limit reads it back), and -d (set $DEBUG to true), which prints a line to standard error every time
an exception is raised, handled or not, and is noisy because Ruby’s own libraries raise and rescue internally.
For a script that fails deep in a call chain:
# frozen_string_literal: true
def level3 = Integer("seven")
def level2 = level3
def level1 = level2
begin
level1
rescue ArgumentError
puts "handled"
end
level1
$ ruby --backtrace-limit=1 deep_failure.rb
handled
deep_failure.rb:3:in 'Kernel#Integer': invalid value for Integer(): "seven" (ArgumentError)
def level3 = Integer("seven")
^^^^^^^
from deep_failure.rb:3:in 'Object#level3'
... 3 levels...
Source: Ruby command-line options
Watching exceptions as they happen
TracePoint (see Reflection, Introspection and Tracing)
has a :raise event, fired when an exception is raised, and a :rescue event (Ruby 3.3, Ruby-level rescue only), fired
when one is handled. It is the right tool for "who raises this and who swallows it" in a codebase that handles too
much. tp.raised_exception is the exception object and tp.lineno and tp.path the location. The event is also
fired for exceptions that Ruby’s own code raises and rescues internally, so filter on tp.path in real use.
# frozen_string_literal: true
def parse(text)
raise ArgumentError, "not a number: #{text}" unless text.match?(/\A\d+\z/)
text.to_i
end
def total(values) = values.sum { |text| parse(text) }
events = []
trace = TracePoint.new(:raise, :rescue) do |tp|
events << [tp.event, tp.raised_exception.class, tp.lineno]
end
trace.enable do
total(%w[1 two 3])
rescue ArgumentError
events << :handled
end
events.each { |event| p event }
[:raise, ArgumentError, 4]
[:rescue, ArgumentError, 19]
:handled
Source: TracePoint
Warnings
Not every problem is an exception. warn writes to standard error and Warning controls which categories of
interpreter warnings are shown:
-
Warning[:deprecated]is off by default (since Ruby 2.7.2),Warning[:experimental]is on,Warning[:performance]is off.Warning.categorieslists the categories::deprecated,:experimental,:performanceand:strict_unused_block(the last two since 3.3 and 3.4). -
ruby -wandruby -W:deprecated(or-W:no-experimental) set them from the command line, and$VERBOSEset tonilsilenceswarnas well. -
warn "message", uplevel: 1, category: :deprecatedreports the line of the caller (theuplevelkeyword) and respects the category switch. This is the pattern for deprecating your own API. The example below is run without-w, which would switch the deprecated category on from the start. -
Warning.warnis the single hook through which every warning passes. Prepending a module to its singleton class captures or reroutes them, for example into a logger or into a test failure (Ruby 3.0 added thecategory:keyword).
# frozen_string_literal: true
p Warning.categories
p Warning[:deprecated], Warning[:experimental], Warning[:performance]
def old_api
warn "old_api is deprecated, use new_api", uplevel: 1, category: :deprecated
:result
end
# Deprecation warnings are off by default; enabling the category shows them.
old_api
Warning[:deprecated] = true
old_api
# Route warnings somewhere else by overriding Warning.warn.
captured = []
Warning.singleton_class.prepend(Module.new do
define_method(:warn) do |message, category: nil|
captured << [category, message.chomp]
nil
end
end)
warn "captured by the hook"
old_api
cleaned = captured.map { |category, message| [category, message.sub(/\A.*?: warning: /, "")] }
p cleaned
[:deprecated, :experimental, :performance, :strict_unused_block]
false
true
false
warnings_demo.rb:14: warning: old_api is deprecated, use new_api
[[nil, "captured by the hook"], [:deprecated, "old_api is deprecated, use new_api"]]
Source: Kernel#warn
Source: Warning
Wrapping and chaining
When a low-level error crosses a layer boundary, wrap it in the error type of the layer it enters, so callers depend on
the library’s errors and not on its implementation (Errno::ENOENT or a JSON::ParserError), and keep the original
as the cause. Ruby does the second part for you: an exception raised inside a rescue clause automatically gets the
exception being handled ($!) as its cause, since Ruby 2.1. Set cause: explicitly to attach a different one, or
cause: nil to hide the implicit one when the original adds nothing.
The uncaught-exception printer and full_message show the whole chain, innermost cause last, as the second half of this
example demonstrates (the script ends with an uncaught exception, so the printed report is the real standard error output):
# frozen_string_literal: true
require "shelf"
module Shelf
class StoreError < Error; end
# Wraps whatever the storage layer raises, keeping the original as cause.
class FileStore
def initialize(path) = @path = path
def load
File.read(@path)
rescue SystemCallError
raise StoreError, "cannot load catalogue from #{@path}"
end
end
end
store = Shelf::FileStore.new("catalogue.json")
begin
store.load
rescue Shelf::StoreError => e
puts "error: #{e.message}"
puts "cause: #{e.cause.inspect}"
chain = [e]
chain << chain.last.cause while chain.last.cause
puts "chain: #{chain.map(&:class).join(' <- ')}"
end
# Uncaught, the whole chain is printed.
store.load
error: cannot load catalogue from catalogue.json
cause: #<Errno::ENOENT: No such file or directory @ rb_sysopen - catalogue.json>
chain: Shelf::StoreError <- Errno::ENOENT
wrap_cause.rb:15:in 'Shelf::FileStore#load': cannot load catalogue from catalogue.json (Shelf::StoreError)
from wrap_cause.rb:33:in '<main>'
wrap_cause.rb:13:in 'IO.read': No such file or directory @ rb_sysopen - catalogue.json (Errno::ENOENT)
from wrap_cause.rb:13:in 'Shelf::FileStore#load'
from wrap_cause.rb:33:in '<main>'
Source: Exception#cause
detailed_message shows only the exception itself; use full_message or walk cause to include the chain. A cause is
a link in a singly linked list, so a loop such as chain << chain.last.cause while chain.last.cause is enough to
collect it. Wrap deliberately: a wrapper that discards the original message (raise Shelf::StoreError) forces the
reader to find the cause in a different place, so the wrapper’s message should say what the layer was doing, and the
cause says why it failed.
Exceptions and control flow
An exception is the wrong tool for something that happens in normal operation. A missing hash key, a number that may not parse, a search that finds nothing: these have return values. Ruby gives most of the core methods both forms, so choose by whether the failure is expected:
| When absence or failure is expected | When it is a bug or an environmental fault |
|---|---|
|
|
|
|
|
A method that must find something and raises when it cannot, conventionally named with |
The safe navigation operator |
|
|
|
catch and throw (with break, next, return and redo, in
Control Expressions) implement non-local exits that are not
errors. throw tag, value unwinds the stack to the nearest catch(tag) with the same tag (compared by identity;
symbols are conventional) and makes catch return the value. Without a matching catch, throw raises
UncaughtThrowError, a subclass of ArgumentError that carries tag and value. loop rescues StopIteration for
you and returns the iterator’s result, which is what makes loop { enum.next } a safe way to walk an enumerator.
# frozen_string_literal: true
# 1. Expected absence is a value, not an exception.
p Integer("12", exception: false)
p Integer("twelve", exception: false)
p Float("1.5x", exception: false)
catalog = { "9780306406157" => "Ruby Basics" }
p catalog.fetch("0000000000000", "unknown")
p catalog.fetch("0000000000000") { |isbn| "no title for #{isbn}" }
begin
catalog.fetch("0000000000000")
rescue KeyError => e
puts "#{e.message} (key: #{e.key.inspect})"
end
# 2. catch/throw leaves nested loops without an exception class of its own.
shelves = [%w[a b c], %w[d target f], %w[g h i]]
found = catch(:found) do
shelves.each_with_index do |shelf, row|
shelf.each_with_index do |title, col|
throw :found, [row, col] if title == "target"
end
end
nil
end
p found
# throw without a matching catch is an UncaughtThrowError (an ArgumentError).
begin
throw :missing, 42
rescue UncaughtThrowError => e
puts "#{e.message}; tag=#{e.tag.inspect} value=#{e.value}"
end
# 3. loop rescues StopIteration and returns the enumerator's result.
numbers = [10, 20].each
result = loop do
puts numbers.next
end
p result
# 4. Searching: find returns nil, find with an ifnone lambda computes a default.
titles = ["Ruby Basics", "Rust Basics"]
missing = titles.find { it.start_with?("Go") }
fallback = titles.find(-> { "none" }) { it.start_with?("Go") }
p missing, fallback
12
nil
nil
"unknown"
"no title for 0000000000000"
key not found: "0000000000000" (key: "0000000000000")
[1, 1]
uncaught throw :missing; tag=:missing value=42
10
20
[10, 20]
nil
"none"
Source: Kernel#catch
Source: Kernel#throw, Kernel#Integer, Kernel#loop
Two further reasons not to use exceptions for ordinary branching: creating one captures a backtrace, which costs far more
than returning a value, and it makes the happy path unreadable because the reader must look for the rescue far from the
call. Lint/SuppressedExceptionInNumberConversion goes after the most common abuse, Integer(text) rescue 0.
Fail fast or recover
The decision a handler makes, in order:
-
Can this code repair the situation? A missing optional file can fall back to defaults; a transient network failure can be retried with backoff. Then rescue the specific class, repair, and continue.
-
Can it add context and pass it on? Wrap into the layer’s own error with
cause, or add a message withraise error, "more context", and let the caller decide. -
Neither? Do not rescue. Let the exception travel to the one place at the edge of the program that reports and exits.
Failing fast means raising at the first sign of an invalid state (a precondition on an argument, an invariant of a value
object) rather than letting a nil travel three layers and fail with a confusing NoMethodError. Shelf::Isbn.new
does this: an invalid ISBN never becomes an object.
Timeouts
Timeout.timeout(seconds) { ... } from the timeout default gem (require "timeout") interrupts the block when the
time is up. It is simple, and its limits matter:
-
The interruption is an exception injected into the thread at an arbitrary point. Inside the block, Ruby code that is running at that moment is interrupted wherever it happens to be, including in the middle of updating a data structure.
-
To stop the block’s own
rescue => efrom swallowing the timeout, Ruby raisesTimeout::ExitException, anException, inside the block and converts it intoTimeout::Error(aRuntimeError) at theTimeout.timeoutcall. If you pass your own class as the second argument (Timeout.timeout(5, Shelf::StoreError, "store too slow")), that class is raised inside the block as is, and arescue StandardErrorin the block can catch it. -
ensureclauses in the block run, and can themselves be interrupted. The documentation recommends putting theTimeout.timeoutcall inside abegin/ensurerather than the other way round, andThread.handle_interruptfor the hard cases. -
A block can defeat the timeout by rescuing the exception or by never reaching a point where the interruption is delivered, so the method "cannot be relied on to enforce timeouts for untrusted blocks".
-
0andnilmean "no limit"; a negative number is anArgumentError(Ruby 3.4).
# frozen_string_literal: true
require "timeout"
class SlowStoreError < StandardError; end
# Finishes in time: returns the block's value.
p Timeout.timeout(1) { :fast }
# Expires: Timeout::Error, a RuntimeError, raised where the call started.
begin
Timeout.timeout(0.05) { sleep }
rescue Timeout::Error => e
puts "#{e.class}: #{e.message}"
p Timeout::Error.ancestors.take(3)
end
# The exception that interrupts the block is Timeout::ExitException, an
# Exception, so the block's own `rescue => e` cannot swallow it.
begin
Timeout.timeout(0.05) do
sleep
rescue StandardError
puts "never printed"
ensure
puts "ensure still runs"
end
rescue Timeout::Error => e
puts "outside: #{e.class}"
end
# A custom class and message are raised at the interrupted point instead.
begin
Timeout.timeout(0.05, SlowStoreError, "store too slow") { sleep }
rescue SlowStoreError => e
puts "#{e.class}: #{e.message}"
end
# nil and 0 mean no limit; a negative limit is rejected.
p Timeout.timeout(nil) { :unlimited }
begin
Timeout.timeout(-1) { :never }
rescue ArgumentError => e
puts e.message
end
# The reliable alternative is a timeout on the operation itself.
r, = IO.pipe
r.timeout = 0.05
begin
r.read(1)
rescue IO::TimeoutError => e
puts "#{e.class}: #{e.message}"
end
:fast
Timeout::Error: execution expired
[Timeout::Error, RuntimeError, StandardError]
ensure still runs
outside: Timeout::Error
SlowStoreError: store too slow
:unlimited
Timeout sec must be a non-negative number
IO::TimeoutError: Blocking operation timed out!
Source: Timeout.timeout
Source: Timeout, IO#timeout=
Where the operation itself offers a timeout, use that instead: it fails at a well-defined point and without
interrupting arbitrary code. IO#timeout= makes blocking reads and writes raise IO::TimeoutError (Ruby 3.2),
Thread::Queue#pop(timeout:) returns nil (3.2), Regexp.timeout= bounds a match (3.2), Socket.tcp and
TCPSocket.new accept open_timeout: and raise IO::TimeoutError consistently (4.0), and Net::HTTP has
open_timeout, read_timeout and write_timeout (see
Networking and HTTP). A fiber scheduler can implement
timeout_after, which makes Timeout.timeout non-blocking (3.1).
Exceptions in threads, fibers and ractors
An exception that is not rescued in a thread ends that thread only. The main thread is not told until it asks:
Thread#join and Thread#value re-raise the exception in the caller. Meanwhile the interpreter prints a report to
standard error when the thread dies (Thread.report_on_exception, true by default since Ruby 2.5, settable per thread),
and Thread.abort_on_exception = true turns any thread’s failure into an exception in the main thread, which is
convenient for scripts and wrong for servers.
Thread#raise injects an exception into another thread, the same mechanism that Timeout uses; since Ruby 4.0 it
accepts cause: like Kernel#raise, and so does Fiber#raise. In a fiber, an unrescued exception propagates to
whoever called resume. In a ractor, an exception ends the ractor and is delivered to the caller as
Ractor::RemoteError with the original as its cause. The concurrency pages go deeper:
Threads and the GVL,
Fibers and the Fiber Scheduler and
Ractors.
# frozen_string_literal: true
Thread.report_on_exception = false
worker = Thread.new { Integer("not a number") }
begin
worker.join
rescue ArgumentError => e
puts "join re-raised: #{e.message}"
end
begin
Thread.new { raise IOError, "disk gone" }.value
rescue IOError => e
puts "value re-raised: #{e.message}"
end
puts "status of a failed thread: #{worker.status.inspect}"
# Thread#raise injects an exception into another thread.
sleeper = Thread.new do
sleep
rescue Interrupt => e
"woken by #{e.class}: #{e.message}"
end
Thread.pass until sleeper.status == "sleep"
sleeper.raise(Interrupt, "wake up")
puts sleeper.value
# Since 4.0 the injected exception can carry its own cause.
t = Thread.new { sleep }
Thread.pass until t.status == "sleep"
t.raise(RuntimeError, "stop", cause: ArgumentError.new("because"))
begin
t.join
rescue RuntimeError => e
puts "#{e.message} <- #{e.cause.message}"
end
join re-raised: invalid value for Integer(): "not a number"
value re-raised: disk gone
status of a failed thread: nil
woken by Interrupt: wake up
stop <- because
Source: Thread
Source: Thread.report_on_exception, ThreadError
Ending the program
A Ruby program ends in one of four ways, each with a different exit status and different cleanup:
| How | Status | What runs |
|---|---|---|
Falling off the end of the script |
0 |
|
|
0, 0, 1, n |
Raises |
|
1 |
Prints the message to standard error, then behaves like |
An uncaught exception |
1 |
|
|
1 by default, or the given status |
Ends the process immediately: no |
An exit inside an at_exit handler changes the final status. END { } is a keyword form of at_exit that registers once
(RuboCop’s Style/EndBlock asks for at_exit). Signals reach Ruby as exceptions in the
main thread: Ctrl-C raises Interrupt (a SignalException with signo 2) and kill -TERM raises SignalException
SIGTERM; handlers installed with Signal.trap replace that behaviour (see
Processes, Signals and Shell).
The following program starts a child Ruby process for each ending and prints its status and output, so the cleanup behaviour is visible:
# frozen_string_literal: true
require "English"
require "rbconfig"
# Each child process shows one way a Ruby program ends.
CHILDREN = {
"normal end" => "puts 'done'",
"exit" => "exit",
"exit(false)" => "exit(false)",
"exit 3" => "exit 3",
"abort" => "abort 'fatal: catalogue corrupt'",
"uncaught raise" => "raise 'boom'",
"rescued SystemExit" => <<~'RUBY'.tr("\n", ";"),
begin
exit 9
rescue SystemExit => e
puts "rescued status=#{e.status}"
end
RUBY
"ensure and at_exit" => <<~RUBY.tr("\n", ";"),
at_exit { puts 'at_exit 1' }
at_exit { puts 'at_exit 2' }
begin
exit 4
ensure
puts 'ensure'
end
RUBY
"exit!" => <<~RUBY.tr("\n", ";")
at_exit { puts 'skipped' }
begin
exit! 5
ensure
puts 'skipped too'
end
RUBY
}.freeze
CHILDREN.each do |label, code|
output = IO.popen([RbConfig.ruby, "-e", code, { err: %i[child out] }], &:read)
puts "#{label}: status=#{$CHILD_STATUS.exitstatus}"
output.each_line { |line| puts " #{line}" }
end
normal end: status=0
done
exit: status=0
exit(false): status=1
exit 3: status=3
abort: status=1
fatal: catalogue corrupt
uncaught raise: status=1
-e:1:in '<main>': boom (RuntimeError)
rescued SystemExit: status=0
rescued status=9
ensure and at_exit: status=4
ensure
at_exit 2
at_exit 1
exit!: status=5
Source: Kernel#exit
Source: Kernel#abort, Kernel#at_exit, SystemExit
Interrupt handling in a script follows from the table. A program that has resources to release should rely on ensure,
which runs for Interrupt and SystemExit too, and should not rescue Interrupt unless it has something to say:
# frozen_string_literal: true
# Ctrl-C arrives as an Interrupt raised in the main thread.
begin
Process.kill("INT", Process.pid)
sleep
rescue Interrupt => e
puts "#{e.class} (a #{e.class.superclass}), message #{e.message.inspect}, signo #{e.signo}"
end
# Other signals arrive as a plain SignalException.
begin
Process.kill("TERM", Process.pid)
sleep
rescue SignalException => e
puts "#{e.signm} (#{e.signo})"
end
Interrupt (a SignalException), message "", signo 2
SIGTERM (15)
Source: Interrupt
Source: SignalException
Handling errors at the edge of a program
Libraries raise; the program decides. In a command-line program, a service or a background job there is one place, the
outermost method, that knows what a failure means for the user and the process: print a message, choose an exit status,
log with the right severity, and perhaps retry the job. Everything below it just raises, and a rescue below it is
justified only when it repairs or wraps.
The example is the shape most scripts want: a main that returns an exit status, a table that maps error classes to
statuses (the values follow the BSD sysexits.h convention: 64 usage, 65 bad data, 70 internal error), a
Logger rather than puts for diagnostics (severity, destination and format are configurable, and they go to
standard error, not standard output, so the program’s output can be piped), and a single exit in the script body. The
logger library is a bundled gem since Ruby 4.0, so a project that runs under Bundler needs gem "logger" in its
Gemfile.
# frozen_string_literal: true
require "logger"
require "shelf"
LOGGER = Logger.new($stderr, formatter: proc { |severity, _time, _prog, message| "#{severity}: #{message}\n" })
module Shelf
class UsageError < Error; end
end
# Exit statuses follow sysexits.h: 64 usage, 65 bad data, 70 internal error.
STATUS = { Shelf::UsageError => 64, ArgumentError => 65 }.freeze
def run(argv)
raise Shelf::UsageError, "usage: shelf ISBN" if argv.empty?
puts Shelf::Isbn.new(argv.first)
end
# One place decides how failures are reported and which status the process
# returns; everything below it just raises.
def main(argv)
run(argv)
0
rescue Shelf::Error, ArgumentError => e
LOGGER.warn("#{e.class}: #{e.message}")
STATUS.fetch(e.class, 64)
rescue StandardError => e
LOGGER.error("#{e.class}: #{e.message}")
70
end
[[], ["123"], ["978-0-306-40615-7"]].each do |argv|
puts "status #{main(argv)}"
end
WARN: Shelf::UsageError: usage: shelf ISBN
status 64
WARN: ArgumentError: invalid ISBN: 123
status 65
9780306406157
status 0
Source: Kernel#exit
Command-line details such as OptionParser error classes and exit status conventions are in
Command-Line Programs. For network code, remember that the
errors worth rescuing from Net::HTTP are the transport errors (SocketError, Errno::ECONNREFUSED,
Timeout::Error/Net::OpenTimeout/Net::ReadTimeout, OpenSSL::SSL::SSLError, EOFError), which are not
subclasses of any Net::HTTP error class; a rescue Net::HTTPError handler written from an older tutorial misses
them. That list lives with the HTTP client in Networking and HTTP.
Testing error paths
An error path that is not tested is a guess. Minitest’s assert_raises(Klass) { ... } passes when the block raises
Klass or a subclass, fails when it raises nothing or something else, and returns the exception so that the message,
the cause or the extra data can be asserted. RSpec’s equivalent is expect { ... }.to raise_error(Klass, message); see
Testing with Minitest and
RSpec and Test Doubles. Test the class and the data, and
test the message with care: asserting an exact message ties the test to wording, and messages of core classes change
between Ruby versions (see the version notes below).
# frozen_string_literal: true
require "minitest/autorun"
require "shelf"
class IsbnErrorsTest < Minitest::Test
def test_rejects_short_isbn
error = assert_raises(ArgumentError) { Shelf::Isbn.new("978") }
assert_equal "invalid ISBN: 978", error.message
end
def test_subclass_is_accepted
assert_raises(StandardError) { Shelf::Isbn.new("978") }
end
def test_valid_isbn_does_not_raise
assert_equal "9780306406157", Shelf::Isbn.new("978-0-306-40615-7").digits
end
end
3 runs, 4 assertions, 0 failures, 0 errors, 0 skips
Source: Minitest
(The output shows only the summary line of the run; Minitest also prints a random seed and the elapsed time.) minitest
is a bundled gem in Ruby 4.0 (version 6.0.0): a project under Bundler lists it in its Gemfile.
Guidelines
The practices that the sections above justify, as a checklist:
-
Rescue the narrowest class that names the failure you can handle.
rescue StandardErroris acceptable only at a boundary, andrescue Exceptionalmost never. -
Keep the
beginbody to the statements that can raise the handled exception; put the success path inelse. -
Never leave a
rescueclause empty. If an error is deliberately ignored, say why in a comment (RuboCop’sLint/SuppressedExceptionaccepts a commented empty clause) or use a method that has a failure-free form. -
Handle each failure once, at the layer that can act on it; wrap with
causewhen crossing layers; log at the edge. -
Use
ensure, or better a block form, for cleanup; neverreturnfromensure. -
Define a root error per library and subclass it; carry data in readers; do not parse messages.
-
Retry only transient, idempotent operations, with a limit and growing delays; never retry programming errors.
-
Prefer return values (
exception: false,fetchwith a default,find) for expected absence. -
Put timeouts on the operation (
IO#timeout=, HTTP client options) rather than around arbitrary code. -
Test the error paths, and
assert_raisesthe class and the data, not the wording.
RuboCop cops for exceptions
RuboCop ships a family of cops that encode these rules. All of them are enabled by default (the NewCops: enable setting
used by the shelf configuration turns on the cops that are new in each release too).
| Cop | What it reports |
|---|---|
|
|
|
A bare |
|
An empty |
|
|
|
|
|
A |
|
The same class listed twice. |
|
A |
|
|
|
An empty |
|
A |
|
|
|
|
|
|
|
|
|
A |
See Style, Linting and Static Analysis for configuring and running RuboCop.
Pitfalls
-
rescue Exceptionswallowsexitand Ctrl-C. A script that cannot be interrupted usually has one. -
raise Klass, key: valuedoes not call the constructor with a keyword. The keywords become the message. Raise an object:raise Klass.new(...). -
Exceptions in
else,ensureandrescueare not seen by the siblingrescueclauses. Nest abeginif needed. -
returninensurehides the exception in flight. -
A
rescueentry that is not a class (a string, a method that returnednil) raisesTypeErrorwhen an exception arrives, and hides it. -
NotImplementedErroris not rescued by a barerescue. -
retrywithout a limit is an infinite loop for a permanent failure. -
Timeout.timeoutcan interrupt anything, including cleanup. Use the operation’s own timeout. -
Thread.abort_on_exception = truein a library changes the behaviour of the whole process. Usereport_on_exceptionandjoin. -
$!outside arescueclause isnil, and inside it can change when another exception is handled in between. Use the variable ofrescue => e. -
Error messages that include secrets (passwords, tokens, full URLs with credentials) end up in logs and error trackers. Include identifiers, not the data.
-
Matching on message text (
e.message.include?("not found")) breaks when wording changes. Use classes and readers. -
Rescuing
NoMethodErrorto detectnil. Use&., a guard clause or a default; a rescuedNoMethodErroralso hides typos. -
A
rescuein a brace block is a syntax error; usedo/end. -
Rescuing from a hot loop. Raising captures a backtrace; return a value in code that runs often.
Comparison with other languages
| Language | Model | Difference from Ruby |
|---|---|---|
|
Ruby has no checked exceptions and no |
|
|
The closest model. Ruby’s |
|
Unchecked exceptions, |
Ruby’s |
|
Errors are return values ( |
Ruby returns values for expected absence ( |
|
|
Swift makes every throwing call visible with |
Where the books are outdated or wrong
Older books and tutorials on Ruby error handling are still useful for the ideas, but several details have changed or were never right. Wherever a printed example differs from the output on this page, this table says why.
| Area | Older material shows | Current approach (Ruby 4.0) |
|---|---|---|
Error message format |
|
|
Backtrace labels |
|
|
Hash in a message or |
|
|
Abstract methods |
|
|
Catch-all handling |
|
|
Wrapping errors |
A plain |
A |
Network errors |
|
The transport errors ( |
Logging |
|
|
Printing order |
Output headed "Traceback (most recent call last)", outermost frame first (an experiment of Ruby 2.5 to 2.7 on terminals). |
Innermost first again since Ruby 3.0; |
Cause |
A custom attribute ( |
|
Pattern-matching errors |
Not covered. |
|
Version notes
| Version | Change |
|---|---|
2.1 |
|
2.5 |
|
3.0 |
The error printer shows the innermost frame first. |
3.1 |
|
3.2 |
|
3.3 |
|
3.4 |
Error messages and backtraces use an apostrophe instead of a backtick, and method labels include the class. Extra |
4.0 |
|