Skip to content
VLSI Mentor

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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__)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
vcs exited 137
ParseError

Printing 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:

Azvya Education Pvt. Ltd.VLSI Mentor
tool.py (BROKEN ON PURPOSE)
class ToolError(RuntimeError):
    """The simulator itself failed."""


def run():
    raise ToolError("vcs exited 137 (out of memory)")


run()
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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"))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
elaborate: ok
  attempt 1 failed (licence server busy) -- retrying
flaky: ok
broken: giving up immediately -- no UVM_INFO summary line found

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
elab: ok
ToolError: vcs exited 137 (out of memory)
ParseError: no UVM_INFO summary line found

One 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class FlowError(Exception):
    """Anything this flow raises deliberately."""


class ParseError(FlowError):
    """A tool's output did not say what we expected."""


print(ParseError.__mro__)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
(<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:

Azvya Education Pvt. Ltd.VLSI Mentor
narrow.py (BROKEN ON PURPOSE)
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 device

A 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:

Azvya Education Pvt. Ltd.VLSI Mentor
chained.py (BROKEN ON PURPOSE)
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 "))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

TypeParentRaised when
FlowErrorExceptionnever directly — it is the family name
ToolErrorFlowErrora tool exited badly or was missing
ParseErrorFlowErroroutput was readable but wrong
ConfigErrorFlowErrorthe configuration makes no sense

Three leaf types and a base covers most of what a regression wrapper needs.

9. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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__}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
none: ok
tool: ToolError -> vcs exited 137
value: not ours -> ValueError

tool 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: RuntimeError for execution failures, ValueError for bad data, Exception when 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 like OSError still crash.
  • except ordering applies to your types too: most derived first.
  • raise ... from e keeps 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.