Python Part I · Module 14 · Errors and Exception Handling
Your Own Error Types for Tool and Parse Failures
Chapter 14.7 raised ValueError for a bad seed, which was right, because a bad seed is exactly what ValueError means. But a simulator that exits with status 137 is not a value problem, and neither is a log file that parsed cleanly and simply never mentioned the thing you needed. Reaching for RuntimeError in both cases works and tells a caller nothing. Two lines of code give each failure its own name — and the reason to write them has little to do with how the code reads, and everything to do with what somebody calling your function is then able to do about it.
Intermediate16 min readPythonPart IFundamentalsErrorsExceptions
Part I · Module 14 · Chapter 14.8 · Your Own Error Types for Tool and Parse Failures
1. Why the Built-In Types Run Out
A flow script fails in ways Python has no name for:
- the simulator exited non-zero;
- the log exists and is well-formed but has no summary line;
- the licence server refused;
- a required tool is not on the path.
None of those is a ValueError. You could raise RuntimeError for all four,
and then a caller who wants to retry the licence failure — and only that one —
has no way to ask.
2. The Recipe
An exception type is a class that inherits from an existing exception. Two lines each:
class ToolError(RuntimeError):
"""A tool we invoked failed."""
class ParseError(ValueError):
"""A tool's output did not say what we expected."""
print(ToolError("vcs exited 137"))
print(type(ParseError("no summary line")).__name__)vcs exited 137
ParseErrorPrinting the exception shows its message; type(e).__name__ recovers its
type name. Both halves are what a traceback's last line is built from.
That is the whole construct. No body is needed beyond a docstring — the docstring is the body, and it is doing real work by documenting what the type means.
Choose the parent by what the failure resembles. RuntimeError for "something
went wrong during execution", ValueError for "the data was wrong". If nothing
fits, inherit from Exception directly.
3. The Name Reaches the Traceback
The type name is the first thing on the last line of a traceback, which is the line people actually read:
class ToolError(RuntimeError):
"""The simulator itself failed."""
def run():
raise ToolError("vcs exited 137 (out of memory)")
run()Traceback (most recent call last):
File "tool.py", line 9, in <module>
run()
~~~^^
File "tool.py", line 6, in run
raise ToolError("vcs exited 137 (out of memory)")
ToolError: vcs exited 137 (out of memory)ToolError: vcs exited 137 says category and detail in one line. Compare
RuntimeError: vcs exited 137, which says "something went wrong" and then the
detail. The classification is free once the type exists.
4. The Real Payoff Is to the Caller
Readability is a weak argument. Here is the strong one — a caller that retries a flaky tool but must not retry a genuinely malformed log, because retrying will produce the identical malformed log and waste an hour:
class ToolError(RuntimeError):
"""A tool we invoked failed — possibly transient."""
class ParseError(ValueError):
"""The output was readable but did not say what we expected."""
ATTEMPTS = {"flaky": 0}
def run_stage(name):
"""flaky fails once then succeeds; broken always fails to parse."""
if name == "flaky":
ATTEMPTS["flaky"] += 1
if ATTEMPTS["flaky"] < 2:
raise ToolError("licence server busy")
return "flaky: ok"
if name == "broken":
raise ParseError("no UVM_INFO summary line found")
return f"{name}: ok"
def with_retry(name, limit=3):
for attempt in range(1, limit + 1):
try:
return run_stage(name)
except ToolError as e:
print(f" attempt {attempt} failed ({e}) -- retrying")
except ParseError as e:
return f"{name}: giving up immediately -- {e}"
return f"{name}: failed after {limit} attempts"
print(with_retry("elaborate"))
print(with_retry("flaky"))
print(with_retry("broken"))elaborate: ok
attempt 1 failed (licence server busy) -- retrying
flaky: ok
broken: giving up immediately -- no UVM_INFO summary line foundThe retry policy is a direct consequence of the type. With one shared
RuntimeError this function could not be written at all — it would either retry
the unparseable log three times or refuse to retry the transient licence
failure. The two lines in Section 2 are what make the distinction expressible.
A custom exception type costs the author two lines and buys the caller a decision.
5. A Shared Base for the Whole Flow
Give the family a common parent and a caller gains a second, broader option:
class FlowError(Exception):
"""Anything this flow raises deliberately."""
class ToolError(FlowError):
"""A tool we invoked failed."""
class ParseError(FlowError):
"""A tool's output did not say what we expected."""
def stage(name):
if name == "sim":
raise ToolError("vcs exited 137 (out of memory)")
if name == "parse":
raise ParseError("no UVM_INFO summary line found")
return f"{name}: ok"
for s in ["elab", "sim", "parse"]:
try:
print(stage(s))
except FlowError as e:
print(f"{type(e).__name__}: {e}")elab: ok
ToolError: vcs exited 137 (out of memory)
ParseError: no UVM_INFO summary line foundOne clause caught both, and type(e).__name__ still recovers which arrived. A
caller now has three levels of precision available: except ToolError for one
case, except FlowError for everything you raise on purpose, or no handler at
all.
The chain is visible:
class FlowError(Exception):
"""Anything this flow raises deliberately."""
class ParseError(FlowError):
"""A tool's output did not say what we expected."""
print(ParseError.__mro__)(<class '__main__.ParseError'>, <class '__main__.FlowError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)Reading it with Chapter 14.5 Section 5's rule: a ParseError is a FlowError
is an Exception. So any of those three except clauses catches it, and the
ordering rule applies — ParseError before FlowError, always.
6. The Base Must Stay Narrow
The point of except FlowError is that it catches what you raise and nothing
else:
class FlowError(Exception):
"""Anything this flow raises deliberately."""
def stage(name):
if name == "disk":
raise OSError("no space left on device")
return f"{name}: ok"
try:
stage("disk")
except FlowError as e:
print("caught as FlowError:", e)Traceback (most recent call last):
File "narrow.py", line 12, in <module>
stage("disk")
~~~~~^^^^^^^^
File "narrow.py", line 7, in stage
raise OSError("no space left on device")
OSError: no space left on deviceA full disk is not something this flow raises deliberately, so FlowError does
not catch it and it propagates with its own traceback — which is correct. That
is the difference between a narrow base and except Exception: the base
expresses the failures you anticipated, and leaves everything else to crash
the way Chapter 14.4 argued it should.
This is also why inheriting your base from Exception rather than
BaseException matters, and Chapter 14.9 shows what goes wrong when that line
is crossed.
7. Keeping the Original Cause
A custom type usually wraps something more primitive, and raising it inside a
handler would normally discard what you knew. raise ... from e keeps both:
class ParseError(ValueError):
"""The log was readable but did not say what we expected."""
def parse_runtime(line):
return float(line.split("=")[1])
def load(line):
try:
return parse_runtime(line)
except ValueError as e:
raise ParseError(f"bad runtime line {line!r}") from e
print(load("runtime_s=182.4"))
print(load("runtime_s= N/A "))182.4
Traceback (most recent call last):
File "chained.py", line 11, in load
return parse_runtime(line)
File "chained.py", line 6, in parse_runtime
return float(line.split("=")[1])
ValueError: could not convert string to float: ' N/A '
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "chained.py", line 17, in <module>
print(load("runtime_s= N/A "))
~~~~^^^^^^^^^^^^^^^^^^^
File "chained.py", line 13, in load
raise ParseError(f"bad runtime line {line!r}") from e
ParseError: bad runtime line 'runtime_s= N/A 'Two tracebacks, joined by one sentence. The lower half is your error, with
the context a caller needs: which line was bad. The upper half is the original
ValueError, which is the only thing that says why — the field was ' N/A ',
spaces included.
Without from e you would get only the lower half, and "bad runtime line" is a
much weaker bug report than "could not convert ' N/A '".
8. How Many Types Is Too Many
A small flow needs two or three. The useful test is Section 4's:
Would a caller do something different?
If LicenceError and ToolError would always be handled identically, one type
with a good message is better. If the licence case deserves a longer wait and
the tool case deserves an immediate stop, they are two types.
A practical shape for a flow script:
| Type | Parent | Raised when |
|---|---|---|
FlowError | Exception | never directly — it is the family name |
ToolError | FlowError | a tool exited badly or was missing |
ParseError | FlowError | output was readable but wrong |
ConfigError | FlowError | the configuration makes no sense |
Three leaf types and a base covers most of what a regression wrapper needs.
9. Predict, Run, Observe
class FlowError(Exception):
"""Anything this flow raises deliberately."""
class ToolError(FlowError):
"""A tool we invoked failed."""
def check(kind):
if kind == "tool":
raise ToolError("vcs exited 137")
if kind == "value":
raise ValueError("seed must not be negative: -1")
return "ok"
for kind in ["none", "tool", "value"]:
try:
print(f"{kind}: {check(kind)}")
except ToolError as e:
print(f"{kind}: ToolError -> {e}")
except FlowError as e:
print(f"{kind}: other FlowError -> {e}")
except Exception as e:
print(f"{kind}: not ours -> {type(e).__name__}")none: ok
tool: ToolError -> vcs exited 137
value: not ours -> ValueErrortool matched the first clause, not the second, because the clauses are
ordered most-derived first — Chapter 14.5's rule, now applied to types you
defined.
value fell through both, because a ValueError is not a FlowError. It
reached the general clause, which deliberately reports the type name rather than
pretending to understand it.
The middle clause never ran in this run. That is fine — it exists for
ParseError and ConfigError. But note Chapter 14.5 Exercise 6: if no input
could ever reach a clause, it is dead code.
10. Exercises
Exercise 1 — two types. Define ToolError and ParseError and raise each
from a function. Confirm the type name appears on the last line of each
traceback.
Exercise 2 — the caller's decision. Write a retry wrapper that retries one of
your types and immediately gives up on the other. Then try to write the same
function with both failures raising RuntimeError, and say what blocks you.
Exercise 3 — the family. Add a FlowError base and confirm one clause
catches both children while type(e).__name__ still distinguishes them.
Exercise 4 — the chain. Print __mro__ for your ParseError. Say which
three except clauses would catch it.
Exercise 5 — narrowness. Raise an OSError from inside a function guarded by
except FlowError and confirm it propagates. Explain in one sentence why that
is the desired behaviour.
Exercise 6 — ordering. Put except FlowError above except ToolError and
show the second becomes unreachable. Which chapter's rule is this?
Exercise 7 — from e. Wrap a ValueError in a ParseError, once with
from e and once without. Compare the two tracebacks and say what the second
loses.
Exercise 8 — how many. For a script that launches a tool, parses its log and writes a report, list the error types you would define. Justify each one with the Section 8 test, and say which you deliberately did not create.
11. Summary
- A custom exception type is a class inheriting from an existing exception — two lines, with a docstring as the body.
- Module 16 explains classes; this chapter uses one form and no other object-oriented construct.
- Choose the parent by resemblance:
RuntimeErrorfor execution failures,ValueErrorfor bad data,Exceptionwhen neither fits. - The type name reaches the last line of the traceback, classifying the failure for whoever reads it.
- The real payoff is to the caller, who can act differently per type — retry a flaky tool without retrying a malformed log.
- The test for creating a type: would a caller do something different?
- A shared base lets one clause catch everything you raise deliberately while
type(e).__name__still recovers the specific type. - Keep the base narrow — inherit from
Exception, so unanticipated failures likeOSErrorstill crash. exceptordering applies to your types too: most derived first.raise ... from ekeeps the original exception as the documented cause, producing two joined tracebacks.
The last chapter of the module is about the two lines that undo all of this, and why they are more expensive than any bug they hide.
Where this fits
Part of the Python curriculum.
