Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Failure & Observability  ›  Lesson

The Hierarchy, `raise from` & `finally`

Exception Hierarchy 28 minExceptions are classes in a tree; catch the right branch, chain one error to its cause, and clean up on every way out
You're building a piece ofQuarry — the black box
This piece — classify_error(): Decides whether a failure means skip the row, stop the run or crash as a bug — and lists the chain of errors that caused it.
Scenario A renamed column once made Quarry skip every row in silence; now each failure says whether to skip, stop or crash, and what caused it.
Your task
Build classify_error(stage, raw): call load_row(stage, raw). Return {"category": "ok", "chain": []} if it succeeds. Otherwise the category is "bad-row" for a BadRowError, "source" for a SourceError and "bug" for any other exception, and the chain lists class names from the caught error down to its root cause (follow __cause__, else __context__ unless suppressed). Example: classify_error("yard", "id=a2;ms=fast") → {"category": "bad-row", "chain": ["BadRowError", "ValueError"]}.

The Hierarchy, raise from & finally

In short: every exception is a class, arranged in a family tree, and except Cls: means "is this an instance of Cls, or any subclass of it?" — so a clause catches a whole subtree, and the first clause that matches wins, top to bottom. Exception is the parent of everything that's a program error; BaseException also covers KeyboardInterrupt and SystemExit, which is why except Exception: is the widest net you should ever cast. Quarry adds its own branch under one base class so a caller can catch "anything Quarry raised on purpose" without hiding real bugs, chains a low-level error to the higher-level one it caused with raise New(...) from old, and uses finally to guarantee cleanup runs on every way out — success, a handled error, or one that escapes.

except LookupError catches a whole branch: KeyError and IndexError both sit under it and get caught, but ValueError sits outside the branch and needs its own except clause

Sort a KeyError from a renamed column

Quarry ran all night and reported success: zero errors, zero rows. Somebody had wrapped the loader like this:

try:
    load_everything()
except Exception:
    pass

A renamed column made every row raise KeyError. The except caught every one, said nothing, and the run "succeeded" — a crash would have been better. KeyError is a class, like every exception; __mro__ lists its ancestors, nearest first:

python

Near the top, everything splits in two: BaseException is the root, and directly under it sit exceptions that are not program errors — KeyboardInterrupt, SystemExit. Exception is the parent of every error you will ever raise or catch on purpose. A bare except: catches BaseException too, so a program with one in its main loop can't even be interrupted with Ctrl+C.

A clause catching a whole subtree has a consequence people trip over — order matters, because the first match wins:

What does this print?
row = {"id": "a1"}
try:
    print(row["ms"])
except LookupError:
    print("lookup")
except KeyError:
    print("key")

The right width is about what you'll do next, not which errors might happen: catch narrowly where you can act (except ValueError: around int(text)), and never catch just to silence — an exception you let go becomes a traceback with a line number; one you swallow becomes a mystery. If one row in a thousand is malformed, a batch of n rows is clean with probability 0.999 ** n — under 50% past about 700 rows, so a loader that aborts the whole batch on one bad row fails most of its runs.

Give Quarry its own error branch

The builtin tree describes how something failed. It can't say what Quarry should do about it — so Quarry defines its own branch, under one base class, named for the response, not the symptom:

python

Three decisions are packed into those lines. One base class, so except QuarryError: catches anything Quarry raised on purpose while letting a genuine bug crash loudly. Names for the response: SourceError means stop and retry later, BadRowError means skip this row — the catching code doesn't need to know what went wrong inside. And BadRowError is also a ValueError, via multiple inheritance, so code written before Quarry had its own errors still catches it.

An exception class is an ordinary class, so give it the facts the handler will need instead of making it parse a message:

python
Drawing diagram…

Chain a bad ms value into Quarry's vocabulary

The low-level ValueError from int is the true story, but it's the wrong vocabulary for a caller who just wants to know "skip or stop?". The loader catches it and raises its own error, saying where it came from:

python

raise NEW from OLD stores OLD on NEW.__cause__, and the traceback says "the above exception was the direct cause". Raise inside an except without from and Python remembers the handled exception anyway, as __context__, worded "during handling of the above exception" — deliberately alarmed, because it usually means the handler itself crashed. from None suppresses a noisy original when only the new error's message matters:

What does this print?
try:
    try:
        {"yard": 1}["vult"]
    except KeyError:
        raise ValueError("unknown stage 'vult'") from None
except ValueError as err:
    print(err.__cause__, err.__suppress_context__)

Cleanup belongs in finally, which runs on every way out — success, a handled error, an unhandled one, even a return:

step through it
1def load(raw, events):
2 events.append("open")
3 try:
4 value = int(raw)
5 except ValueError:
6 events.append("skip")
7 value = None
8 else:
9 events.append("keep")
10 finally:
11 events.append("close")
12 return value
13 
14events = []
15load("40", events)
16load("fast", events)
17print(events)

That guarantee is why this batch loader is wrong — "close" only runs when the loop finishes clean:

broken — fix it

The batch is always supposed to be closed, even when a bad row makes it re-raise. Right now "close" is skipped on the failure path. Make the cleanup unconditional.

Traps worth knowing

A bare raise inside an except re-raises the exception being handled, traceback intact — how you log-and-rethrow. A return inside finally replaces whatever was happening, including an in-flight exception, which vanishes without a trace — never do it. And an exception raised inside finally replaces the one already in flight, chained with "during handling" — keep finally bodies small and boring.

Quick practice

What does this print?
class QuarryError(Exception): pass
class BadRowError(QuarryError, ValueError): pass

def kind(exc):
    if isinstance(exc, ValueError):
        return "bad value"
    if isinstance(exc, QuarryError):
        return "quarry"
    return "bug"

print(kind(BadRowError("x")))
What does this print?
try:
    try:
        raise KeyError("stage")
    except KeyError as exc:
        raise ValueError("bad stage") from exc
except ValueError as err:
    print(type(err.__cause__).__name__, err.__suppress_context__)
broken — fix it

close_file() is meant to run no matter what — right now a bad value skips it. Wrap the risky line so closing still happens.


🎯 Your turn

Build classify_error(stage, raw). The starter gives you Quarry's error classes and its real loader, load_row(stage, raw), which can fail three ways: a SourceError for an empty line, a BadRowError chained from a ValueError for a malformed row, and a plain KeyError — a bug — when a field is missing.

Call load_row. Return {"category": ..., "chain": [...]} where the category is "ok", "bad-row", "source" or "bug", and the chain lists the class names from the error you caught down to its root cause, following __cause__ first, then __context__ unless it is suppressed.

classify_error("yard", "id=a1;ms=120")    # → {"category": "ok", "chain": []}
classify_error("yard", "id=a2;ms=fast")   # → {"category": "bad-row", "chain": ["BadRowError", "ValueError"]}
classify_error("vault", "")               # → {"category": "source", "chain": ["SourceError"]}

Press ▶ Run and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upWould this clause catch it?

Write catches(handler, raised). Both arguments are names of builtin exception classes. Return True if except <handler>: would catch an exception of class <raised>, else False.

exception-hierarchyexceptions
DrillAn exception's family line

Write ancestry(name): for the builtin exception class called name, return the names of the class and all its ancestors, nearest first, stopping at BaseException (leave out object).

exception-hierarchy
BuildWhich except clause wins?

A try has several except clauses, given in order as handlers. Each is a builtin class name, or a list of names for a tuple clause like except (TypeError, ValueError):. Write first_handler(handlers, raised) returning the index of the clause that catches an exception of class raised, or -1 if none does and it propagates.

exception-hierarchyexceptions
BossOutline a chained traceback

The starter defines five failure scenarios. Write traceback_outline(case): run SCENARIOS[case](); if it raises, return the outline Python would print, root cause first: class names, with "direct cause" between two errors linked by __cause__ and "during handling" between two linked by an unsuppressed __context__. If nothing is raised, return [].

tracebacksexception-hierarchyexceptions
CapstoneA batch that survives its bad rows

Using Quarry's loader from the starter, write safe_rows(stage, lines). Load each line in order with load_row. A BadRowError means skip that line (record its index); a SourceError means stop the batch at once (record the index); any other exception is a bug and must propagate. Return {"loaded": [ids of loaded rows], "skipped": [indices], "stopped_at": index or None}.

exception-hierarchyexceptionstracebacks
ExplainWhy the silent success was worse than a crash

Quarry's old loader wrapped the whole run in try: ... except Exception: pass and reported success with zero rows after a column was renamed. Explain what was wrong with that handler and what you would write instead.

classify_error.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
classify_error(stage, raw) → dictSort a loader failure into Quarry's buckets and list its chain of causes.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.