Exceptions have a cause¶
Level: 201 · anyone who rescues a low-level error and raises a better one
One line: A raise that runs while a rescue clause is handling another exception records that exception as the new one's cause, automatically and for the whole chain, so wrapping an error never loses the original — cause: sets it by hand, cause: nil hides it, and a loop in the chain is an ArgumentError.
The usual reason to raise inside a rescue is translation: a library's Errno::ENOENT becomes your ConfigError, a driver's timeout becomes PaymentFailed. Ruby keeps the original for you. When raise runs while $! is set — that is, inside a rescue clause, or inside an ensure while an exception is in flight — the new exception's cause is set to $!, and since the original may itself have a cause, cause forms a chain that a handler can walk. raise accepts cause: to set it to any exception object, and cause: nil to hide the one being handled, which is right only when the original is noise a caller should not see. An uncaught exception's report — full_message — prints the whole chain, newest first. Ruby refuses a chain that would loop back on itself, and cause has no setter: it is fixed at the moment of the raise.
Verified output of exceptions_have_a_cause_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. a raise inside rescue sets cause automatically
High: "save failed"; cause: Low: "disk full"
2. cause: sets it explicitly, to any exception object
e.cause.class Low; the same object: true
3. cause: nil hides the exception being handled
e.cause nil
4. a chain of three, walked with cause
[High, Mid, Low]
5. what full_message prints about the chain (labels only)
'<main>': h (High)
'<main>': m (Mid)
'<main>': l (Low)
6. no rescue in progress: cause is nil
e.cause nil
7. a cause chain may not loop
raise a, cause: b while b's cause is a -> ArgumentError: "circular causes"
8. a raise inside ensure, while another exception is in flight, gets a cause too
inner.cause.class Low
the body's Low still reaches the outer rescue
9. cause is read-only
Exception.method_defined?(:cause=) false
Reading the output¶
Row 1 is the automatic case: Low is being handled, High is raised, and the handler for High finds the Low — class and message — in e.cause without anyone having passed it. Row 2 passes cause: explicitly, outside any rescue, and the cause is that very object (equal? is true). Row 3 is cause: nil — the Low being handled is dropped and e.cause is nil.
Row 4 raises Low, then Mid in its handler, then High in that handler, and chain_of walks cause from the outermost: [High, Mid, Low]. Row 5 is what an uncaught High would print: full_message lists every exception in the chain, newest first, each on its own line with its label, message and class (the from lines and the file names are stripped here; the backtrace lesson shows them). Row 6 is the baseline: with no rescue in progress, cause is nil.
Row 7 is the guard: a's handler raises b, and inside b's handler, raising a again with cause: b would make a the cause of b and b the cause of a — Ruby raises ArgumentError with the message circular causes instead. Row 8 shows that cause follows $!, not the keyword rescue: an exception raised inside ensure while the body's Low is still in flight gets that Low as its cause, and the Low itself still reaches the outer handler once the inner one is dealt with. Row 9: there is no cause= — the chain is set at raise time and cannot be edited afterwards.
Compared with Python¶
Python records the same information in two attributes instead of one. __context__ is the implicit link, set exactly as Ruby's cause is — row 1 shows it filled and __cause__ still None. raise X from Y sets __cause__ explicitly (row 2) and also sets __suppress_context__ to True, and raise X from None (row 3) leaves __cause__ at None, keeps the handled exception in __context__, and sets __suppress_context__ so the traceback does not show it — three fields where Ruby has one. Walking a chain therefore reads __cause__ first and falls back to __context__ unless it is suppressed (row 4). Row 5 prints the two sentences that tell the two links apart in a traceback: During handling of the above exception, another exception occurred: for an implicit __context__, and The above exception was the direct cause of the following exception: for an explicit from. Row 7 is a difference: Python accepts a cycle in __cause__ and its traceback printer simply stops when it meets an exception it has already shown. Row 8 matches Ruby — a raise inside finally gets the in-flight exception as its __context__. Row 9 is the other difference: __cause__ and __context__ are ordinary writable attributes.
Verified output of exceptions_have_a_cause_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. a raise inside except sets __context__ automatically
High: 'save failed'; __context__: Low: 'disk full'; __cause__: None
2. raise X from Y sets __cause__ explicitly
e.__cause__ Low; the same object: True; __suppress_context__ True
3. from None hides the exception being handled (it is still in __context__)
e.__cause__ None; e.__context__ Low; __suppress_context__ True
4. a chain of three, walked with __cause__ / __context__
['High', 'Mid', 'Low']
5. what the traceback prints about the chain (sentence lines only)
Low: l
During handling of the above exception, another exception occurred:
Mid: m
During handling of the above exception, another exception occurred:
High: h
... and with an explicit from:
Low: l
The above exception was the direct cause of the following exception:
High: h
6. no except in progress: both are None
e.__context__ None; e.__cause__ None
7. no loop check: a cycle is accepted, and the printer stops at a repeat
a.__cause__ is b and b.__cause__ is a: True
format_exception(a) mentions 'Low: a' 1 time(s)
8. a raise inside finally, while another exception is in flight, gets a __context__ too
inner.__context__ Low
the body's Low still reaches the outer except
9. __cause__ is writable
e.__cause__ after assignment: Low
The Python reference describes both attributes under The raise statement ↗; the tutorial's Exception Chaining ↗ shows the tracebacks the twin only quotes the sentences of.
Try it¶
- Write
load_config(path)that reads a file and, onErrno::ENOENT, raises aConfigErrorwhose message names the path. RescueConfigErrorat the top level and printe.messageande.cause.class; then walk the chain with a loop and print each class with its depth. - Add
cause: nilto theraiseinload_configand show that the chain is now one element long; then write a sentence, as a comment, on when hiding a cause is the right call and when it destroys the evidence a caller needs.
Solution to 1
exceptions_have_a_cause_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Exercise 1: wrap a low-level Errno::ENOENT in a ConfigError that names the path,
# keep the original as the cause, and walk the chain from the handler.
class ConfigError < StandardError; end
def load_config(path)
File.read(path)
rescue Errno::ENOENT
raise ConfigError, "no config at #{path}"
end
puts "1. the handler sees the wrapper and the cause"
begin
load_config("missing.conf")
rescue ConfigError => e
puts " #{e.class}: #{e.message}"
puts " cause: #{e.cause.class}"
end
puts "2. the chain, walked"
begin
load_config("missing.conf")
rescue ConfigError => e
step = e
depth = 0
while step
puts " #{depth}: #{step.class}"
step = step.cause
depth += 1
end
end
Verified output of exceptions_have_a_cause_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
See also¶
raisehas four forms — theraisewhosecause:keyword this page uses, and$!, the variablecauseis read from- Custom exceptions subclass
StandardError— the classes you translate low-level errors into rescue,elseandensurerun in a fixed order — where arescueclause is running, and so wherecausegets set- Backtraces and
caller— the rest of whatfull_messageprints for each link in the chain - Exception#cause ↗ — the reference