Skip to content

A bare rescue catches StandardError, not everything

Level: 201 · anyone who has written rescue => e and wondered what it misses

One line: rescue with no class list means rescue StandardError, and SystemExit, Interrupt, NoMemoryError, SystemStackError and every ScriptError — LoadError, SyntaxError and, surprisingly, NotImplementedError — sit outside that branch, which is why exit still exits through a bare rescue and why rescue Exception is the anti-pattern.

Every Ruby exception descends from Exception, but the tree has two halves. StandardError heads the half a program is expected to handle: RuntimeError, ArgumentError, TypeError, NameError, KeyError, IOError, the Errno family and their subclasses. The other half is for things the interpreter is telling you about — the process is exiting (SystemExit), a signal arrived (Interrupt), memory or stack ran out (NoMemoryError, SystemStackError), or the code itself could not be loaded or is unfinished (ScriptError, with LoadError, SyntaxError and NotImplementedError under it). A rescue clause with no class list catches StandardError and nothing above it, so the second half passes straight through — which is what you want: exit inside a rescued block still exits, Ctrl-C still stops the program, and a method left as raise NotImplementedError is not quietly turned into a return value. rescue Exception catches both halves, and the program shows what that does to exit.

Verified output of standard_error_is_the_default_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. the hierarchy, classes only, up to Exception
   StandardError        StandardError < Exception
   RuntimeError         RuntimeError < StandardError < Exception
   ArgumentError        ArgumentError < StandardError < Exception
   TypeError            TypeError < StandardError < Exception
   NameError            NameError < StandardError < Exception
   NoMethodError        NoMethodError < NameError < StandardError < Exception
   ZeroDivisionError    ZeroDivisionError < StandardError < Exception
   KeyError             KeyError < IndexError < StandardError < Exception
   StopIteration        StopIteration < IndexError < StandardError < Exception
   FrozenError          FrozenError < RuntimeError < StandardError < Exception
   IOError              IOError < StandardError < Exception
   Errno::ENOENT        Errno::ENOENT < SystemCallError < StandardError < Exception
   SystemStackError     SystemStackError < Exception
   NotImplementedError  NotImplementedError < ScriptError < Exception
   LoadError            LoadError < ScriptError < Exception
   SyntaxError          SyntaxError < ScriptError < Exception
   SystemExit           SystemExit < Exception
   Interrupt            Interrupt < SignalException < Exception
   NoMemoryError        NoMemoryError < Exception
2. what a bare rescue catches (a bare rescue inside, rescue Exception outside)
   RuntimeError         caught by the bare rescue
   ArgumentError        caught by the bare rescue
   StopIteration        caught by the bare rescue
   Errno::ENOENT        caught by the bare rescue
   SystemExit           SLIPPED PAST it; rescue Exception caught SystemExit
   Interrupt            SLIPPED PAST it; rescue Exception caught Interrupt
   NoMemoryError        SLIPPED PAST it; rescue Exception caught NoMemoryError
   NotImplementedError  SLIPPED PAST it; rescue Exception caught NotImplementedError
   LoadError            SLIPPED PAST it; rescue Exception caught LoadError
   SystemStackError     SLIPPED PAST it; rescue Exception caught SystemStackError
3. a bare rescue is rescue StandardError
   StandardError === e                 true
   Exception.new.is_a?(StandardError)  false
   StandardError.superclass            Exception
4. rescue Exception swallows exit (the anti-pattern)
   swallowed SystemExit (status 1); the program goes on
5. a real stack overflow is not a StandardError
   slipped past the bare rescue: SystemStackError: stack level too deep
6. a method left as raise NotImplementedError is not caught either
   slipped past the bare rescue: NotImplementedError: Shape must define area

Reading the output

Row 1 prints each class's ancestors, classes only (the mix-ins DidYouMean::Correctable and ErrorHighlight::CoreExt are filtered out), stopping before Object. Read the right-hand end of each line: the first twelve chains pass through StandardError, the other seven do not. A few placements are worth remembering — KeyError and StopIteration are IndexErrors, FrozenError is a RuntimeError, Errno::ENOENT is a SystemCallError — and two are traps: SystemStackError hangs directly off Exception, and NotImplementedError is a ScriptError, beside LoadError and SyntaxError, not a StandardError.

Row 2 raises ten of those classes inside a bare rescue that sits inside a rescue Exception. The first four are caught where you would expect; the other six say SLIPPED PAST it — the bare rescue never ran, and only the outer rescue Exception stopped them. Row 3 states the rule the row demonstrates: a bare rescue is rescue StandardError, Exception itself is not a StandardError, and StandardError's superclass is Exception.

Row 4 is the anti-pattern in one line: exit 1 raises SystemExit, rescue Exception catches it, and the program carries on with the message printed — the process that was told to stop did not. The same clause would swallow a Ctrl-C (Interrupt) and a stack overflow. Rows 5 and 6 are the two cases that bite in real code: a method that calls itself without end raises SystemStackError ("stack level too deep"), and an abstract method left as raise NotImplementedError raises a ScriptError — both pass through rescue => e untouched, and the output shows only the outer, specific rescue seeing them. If you want to catch a NotImplementedError, name it.

Compared with Python

Python's tree has the same two halves but draws the line elsewhere and — the important part — its bare form catches the wrong half. BaseException is the root; Exception is the branch a program handles; only SystemExit, KeyboardInterrupt and GeneratorExit (plus BaseExceptionGroup) sit outside it. Row 1 shows the placements: KeyError and IndexError are LookupErrors, ZeroDivisionError is an ArithmeticError, FileNotFoundError is an OSError, and RecursionError, NotImplementedError and MemoryError are all ordinary Exceptions — the three Ruby keeps outside StandardError. Row 2 confirms it: except Exception catches MemoryError, NotImplementedError and RecursionError, and only SystemExit and KeyboardInterrupt slip past. Row 3 is the reversal: a bare except: is except BaseException:, so it does catch SystemExit — Ruby's bare rescue is the safe default, Python's bare except: is the anti-pattern, and except Exception: is the Python spelling of Ruby's rescue => e. Row 4 shows the consequence — sys.exit(1) swallowed by a bare except:. Rows 5 and 6 print the opposite of the Ruby rows: a real stack overflow (RecursionError, a RuntimeError) and a NotImplementedError (also a RuntimeError) are both caught by except Exception.

Verified output of standard_error_is_the_default_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. the hierarchy, up to BaseException
   Exception            Exception < BaseException
   RuntimeError         RuntimeError < Exception < BaseException
   ValueError           ValueError < Exception < BaseException
   TypeError            TypeError < Exception < BaseException
   NameError            NameError < Exception < BaseException
   AttributeError       AttributeError < Exception < BaseException
   ZeroDivisionError    ZeroDivisionError < ArithmeticError < Exception < BaseException
   KeyError             KeyError < LookupError < Exception < BaseException
   StopIteration        StopIteration < Exception < BaseException
   IndexError           IndexError < LookupError < Exception < BaseException
   OSError              OSError < Exception < BaseException
   FileNotFoundError    FileNotFoundError < OSError < Exception < BaseException
   RecursionError       RecursionError < RuntimeError < Exception < BaseException
   NotImplementedError  NotImplementedError < RuntimeError < Exception < BaseException
   ModuleNotFoundError  ModuleNotFoundError < ImportError < Exception < BaseException
   SyntaxError          SyntaxError < Exception < BaseException
   SystemExit           SystemExit < BaseException
   KeyboardInterrupt    KeyboardInterrupt < BaseException
   MemoryError          MemoryError < Exception < BaseException
2. what except Exception catches (except Exception inside, except BaseException outside)
   RuntimeError         caught by except Exception
   ValueError           caught by except Exception
   StopIteration        caught by except Exception
   FileNotFoundError    caught by except Exception
   SystemExit           SLIPPED PAST it; except BaseException caught SystemExit
   KeyboardInterrupt    SLIPPED PAST it; except BaseException caught KeyboardInterrupt
   MemoryError          caught by except Exception
   NotImplementedError  caught by except Exception
   ModuleNotFoundError  caught by except Exception
   RecursionError       caught by except Exception
3. a bare except is except BaseException -- the opposite default
   bare except caught SystemExit; isinstance(e, Exception): False
   isinstance(Exception(), BaseException)  True
   Exception.__mro__[1].__name__           BaseException
4. a bare except swallows sys.exit (the anti-pattern)
   swallowed SystemExit (code 1); the program goes on
5. a real stack overflow IS an Exception here
   except Exception caught RecursionError
6. NotImplementedError is a RuntimeError, so except Exception catches it
   except Exception caught NotImplementedError

KeyboardInterrupt is what Python's default SIGINT handler raises, and the Python library's Ctrl-C is a signal ↗ shows how signal.signal() replaces that handler; Ruby's Interrupt is the same idea, and the exit lesson here measures it in a child process.

Try it

  1. Write safe_call, a method that yields to its block and returns [:ok, value], or [:error, e.class] when the block raises a StandardError. Show it on a good block, on Integer("abc") and on {}.fetch(:missing); then show that safe_call { exit 2 } lets the SystemExit through — catch it at the top level with rescue SystemExit => e and print e.status.
  2. Write retry_on(*classes, times:) that yields and retries only when the block raises one of the listed classes, re-raising anything else at once. Test it with a block that raises ArgumentError twice and then succeeds, and with a block that raises NotImplementedError.
Solution to 1

standard_error_is_the_default_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Exercise 1: safe_call runs a block and turns a StandardError into a value, while
# letting exit (SystemExit) pass straight through to the top level.

def safe_call
  [:ok, yield]
rescue StandardError => e
  [:error, e.class]
end

puts "1. a value comes back tagged :ok"
p safe_call { 21 * 2 }

puts "2. a StandardError comes back tagged :error"
p safe_call { Integer("abc") }
p safe_call { {}.fetch(:missing) }

puts "3. exit is not a StandardError, so it passes through safe_call"
begin
  safe_call { exit 2 }
  puts "   never printed"
rescue SystemExit => e
  puts "   SystemExit reached the top level with status #{e.status}"
end

Verified output of standard_error_is_the_default_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. a value comes back tagged :ok
[:ok, 42]
2. a StandardError comes back tagged :error
[:error, ArgumentError]
[:error, KeyError]
3. exit is not a StandardError, so it passes through safe_call
   SystemExit reached the top level with status 2

See also