Lesson 13 / 16
Custom Exceptions
A custom exception class's place in the hierarchy decides who catches it: five classes and six catches match in 13 of 30 pairs, the domain root handles seven errors with a single name, the borrowed base catches only three of them, and writing the broad clause first runs the narrow clause zero times.
Contents
Every class in the previous lesson came ready-made. KeyError, ValueError,
FileNotFoundError — their lineages were already built, and the measurement could
group them by common ancestor. We saw that catching an intermediate class catches every
leaf beneath it; someone else had done the placing.
But what if what is raised is the domain’s own error? A measurement device reporting a value outside the accepted range, a required field missing from a record, a raw piece of data that cannot be converted to a number. For these, a class has to be written, and where we hang that class in the lineage is not a matter of style: its place decides who will catch it. That is this lesson’s question.
The Domain’s Own Error Is a Type
There is a measurable difference between describing an error with a message string and
describing it with a type. A message is a string; code reading it has to parse the
string, and the parsing breaks the moment the message changes. A type, by contrast, is
exactly what the except clause tests directly, and the test is done with isinstance.
Writing a custom exception amounts to nothing more than a class definition. The class can hold the domain data it carries as attributes — which field, which value — and catching code reads these without parsing a message.
The real decision is the base. A root class is defined for domain errors, and
concrete errors descend from it; this way a single except line covers all of them.
Beyond that, a concrete class can also descend from a built-in class that fits its
meaning: an out-of-range value is a ValueError, a missing field is a KeyError. This
two-base placement looks like a convenience, and its cost is measured.
Choosing the Root
The measurement builds four domain classes. MeasurementError is the root and descends
directly from Exception. OutOfRangeValue descends from the root and from
ValueError, MissingField from the root and from KeyError. BrokenRecord
descends only from the root. The fifth class, DeviceError, does not descend from the
root — it is a separate tree, and it exists to show what the root does not cover.
The root side of the placement looks like this as a tree; because the borrowed bases sit on the tree’s other branches, they are noted at the end of the line:
# illustrative dump, not executed Exception ├── MeasurementError domain's root │ ├── OutOfRangeValue also ValueError │ ├── MissingField also KeyError │ └── BrokenRecord root only └── DeviceError does not descend from root
The measurement’s assumptions:
- EF10 — The lineage chain is read from the class’s own resolution order, not
written by hand;
objectis not counted because every class descends from it. - EF11 — A pair “matching” means the catch class covers the raised class through
issubclass; this is exactly the test theexceptclause performs, and it can be known in advance at the class level. - EF12 — A “foreign catch” is an
except ValueErrororexcept KeyErrorclause written by code unaware of the domain, for its own purposes; a domain class landing there is a leak. - EF13 — The classes are defined purely for placement; none of them add behavior, so the only thing measured is the place in the hierarchy.
"""Custom exception: the class's place in the hierarchy decides catching behavior.""" class MeasurementError(Exception): """The domain's root: every error handling a measurement record descends from here.""" def __init__(self, field, value=None): super().__init__(field) self.field = field self.value = value def __str__(self): return f"{type(self).__name__}(field={self.field!r}, value={self.value!r})" class OutOfRangeValue(MeasurementError, ValueError): """The value was read but is outside the field's accepted range.""" class MissingField(MeasurementError, KeyError): """The record is missing a required field.""" class BrokenRecord(MeasurementError): """The record could not be converted to a number; it has no built-in counterpart.""" class DeviceError(Exception): """Does not descend from the domain root: a separate tree.""" CLASSES = (("MeasurementError", MeasurementError), ("OutOfRangeValue", OutOfRangeValue), ("MissingField", MissingField), ("BrokenRecord", BrokenRecord), ("DeviceError", DeviceError)) CATCHES = (("MeasurementError", MeasurementError), ("OutOfRangeValue", OutOfRangeValue), ("ValueError", ValueError), ("KeyError", KeyError), ("LookupError", LookupError), ("Exception", Exception)) print("class place in the hierarchy") for name, s in CLASSES: chain = " < ".join(k.__name__ for k in s.__mro__ if k is not object) print(f" {name:18s}{chain}") print() header = "".join(f"{y:<17s}" for y, _ in CATCHES) print(f"{'raised':<20s}{header}") for name, s in CLASSES: cell = "".join(f"{('matches' if issubclass(s, ys) else '-'):<17s}" for _, ys in CATCHES) print(f" {name:<18s}{cell}") print() matches = sum(issubclass(s, ys) for _, s in CLASSES for _, ys in CATCHES) print(f"classes {len(CLASSES)} | catches {len(CATCHES)} | " f"pairs {len(CLASSES) * len(CATCHES)} | matching pairs {matches}") rooted = sum(issubclass(s, MeasurementError) for _, s in CLASSES) leaked = sum(issubclass(s, (ValueError, KeyError)) for _, s in CLASSES) print(f"descend from the domain root {rooted} | leak into a foreign catch {leaked}")
class place in the hierarchy MeasurementError MeasurementError < Exception < BaseException OutOfRangeValue OutOfRangeValue < MeasurementError < ValueError < Exception < BaseException MissingField MissingField < MeasurementError < KeyError < LookupError < Exception < BaseException BrokenRecord BrokenRecord < MeasurementError < Exception < BaseException DeviceError DeviceError < Exception < BaseException raised MeasurementError OutOfRangeValue ValueError KeyError LookupError Exception MeasurementError matches - - - - matches OutOfRangeValue matches matches matches - - matches MissingField matches - - matches matches matches BrokenRecord matches - - - - matches DeviceError - - - - - matches classes 5 | catches 6 | pairs 30 | matching pairs 13 descend from the domain root 4 | leak into a foreign catch 2
What the Borrowed Base Costs
The upper table gives the placement’s result. The MeasurementError column matches in
4 rows — all four classes descending from the root. The DeviceError row is empty;
the root does not cover it, because it does not descend from it. Writing the domain’s
root is writing the domain’s boundary.
What is interesting is the ValueError and KeyError columns. Each matches in 1
row, and those rows are exactly the classes carrying a borrowed base. MissingField
even matches in the LookupError column too: because it descends from KeyError, it
also inherited that class’s ancestor. A single placement decision put the class within
the reach of three separate foreign clauses.
The bottom row counts this: 2 of the four domain classes leak into a foreign catch.
This means the except ValueError clause code unaware of the domain writes for its
int() call will also catch an OutOfRangeValue object — a measurement error mistaken
for and handled as a conversion error. A borrowed base gives the class meaning, and
in exchange, scope.
13 of thirty pairs match. 5 of these come from the Exception column; every
class descends from it, and that column is not distinctive. The distinguishing columns
are the root and the borrowed bases.
Catch Forms and Clause Order
The second measurement runs the classes. Twelve measurement records are processed; part
of them are valid, part produce a domain error, and one produces a program defect —
the coefficient field is written with the wrong type, and the multiplication raises
TypeError. Four catch forms are tried, and three numbers are read for each: how many
names were written, how many domain errors were handled, how many program defects were
swallowed.
- EF14 — Each of the twelve records is really processed; the result class is not labeled by hand, it is read from the raised object.
- EF15 — The oracle is the setup: which record is a domain error and which is a program defect is known from how the records were built.
- EF16 — “Handled” means the caught object is an instance of
MeasurementError; “swallowed defect” means an object that was caught but does not descend from the domain root. - EF17 — “Escaped” is the count of records that do not fall into the form’s clause and pass to the next clause.
- EF18 — In the clause-order measurement, two blocks write the same two classes and only change the order; there is no other difference.
"""Catch forms: how many names are written, how many domain errors handled, how many defects swallowed.""" class MeasurementError(Exception): def __init__(self, field, value=None): super().__init__(field) self.field = field self.value = value def __str__(self): return f"{type(self).__name__}(field={self.field!r}, value={self.value!r})" class OutOfRangeValue(MeasurementError, ValueError): pass class MissingField(MeasurementError, KeyError): pass class BrokenRecord(MeasurementError): pass LOWER_BOUND, UPPER_BOUND = -50.0, 60.0 RECORDS = ( {"value": "12.5", "coefficient": 2}, {"value": "-3", "coefficient": 1}, {"value": "99", "coefficient": 1}, {"coefficient": 1}, {"value": "north", "coefficient": 1}, {"value": "0", "coefficient": 3}, {"value": "-80", "coefficient": 1}, {"value": None, "coefficient": 1}, {"value": "45", "coefficient": "2"}, {"coefficient": 5}, {"value": "60", "coefficient": 1}, {"value": "60.1", "coefficient": 1}, ) def process(record): if "value" not in record: raise MissingField("value") raw = record["value"] try: number = float(raw) except (TypeError, ValueError): raise BrokenRecord("value", raw) from None if not LOWER_BOUND <= number <= UPPER_BOUND: raise OutOfRangeValue("value", number) return record["coefficient"] * number def class_name(record): try: process(record) except BaseException as e: return type(e).__name__ return "-" results = [class_name(r) for r in RECORDS] print("record results:", ", ".join(f"{i}:{s}" for i, s in enumerate(results, 1))) domain_count = sum(s in ("OutOfRangeValue", "MissingField", "BrokenRecord") for s in results) defect_count = sum(s == "TypeError" for s in results) print(f"records {len(RECORDS)} | successful {results.count('-')} | " f"domain error {domain_count} | program defect {defect_count}") FORMS = ( ("three names", (OutOfRangeValue, MissingField, BrokenRecord), 3), ("domain root", (MeasurementError,), 1), ("borrowed base", (ValueError,), 1), ("broad", (Exception,), 1), ) print() print(f"{'catch form':<17s}{'names written':>14s}{'handled':>12s}" f"{'swallowed defect':>18s}{'escaped':>12s}") for name, cls, count in FORMS: handled = swallowed = escaped = 0 for r in RECORDS: try: process(r) except cls as e: if isinstance(e, MeasurementError): handled += 1 else: swallowed += 1 except Exception: escaped += 1 print(f" {name:<15s}{count:>14d}{handled:>12d}{swallowed:>18d}{escaped:>12d}") print() root_first = {"MeasurementError": 0, "OutOfRangeValue": 0} for r in RECORDS: try: process(r) except MeasurementError: root_first["MeasurementError"] += 1 except OutOfRangeValue: root_first["OutOfRangeValue"] += 1 except Exception: pass narrow_first = {"OutOfRangeValue": 0, "MeasurementError": 0} for r in RECORDS: try: process(r) except OutOfRangeValue: narrow_first["OutOfRangeValue"] += 1 except MeasurementError: narrow_first["MeasurementError"] += 1 except Exception: pass print("clause order first clause second clause") print(f" root first MeasurementError {root_first['MeasurementError']:<13d}" f" OutOfRangeValue {root_first['OutOfRangeValue']}") print(f" narrow first OutOfRangeValue {narrow_first['OutOfRangeValue']:<9d}" f" MeasurementError {narrow_first['MeasurementError']}") print() print("domain errors reported through their attributes:") for i, r in enumerate(RECORDS, 1): try: process(r) except MeasurementError as e: print(f" record {i:2d}: {e} -> field {e.field!r}, value {e.value!r}") except Exception: pass
record results: 1:-, 2:-, 3:OutOfRangeValue, 4:MissingField, 5:BrokenRecord, 6:-, 7:OutOfRangeValue, 8:BrokenRecord, 9:TypeError, 10:MissingField, 11:-, 12:OutOfRangeValue records 12 | successful 4 | domain error 7 | program defect 1 catch form names written handled swallowed defect escaped three names 3 7 0 1 domain root 1 7 0 1 borrowed base 1 3 0 5 broad 1 7 1 0 clause order first clause second clause root first MeasurementError 7 OutOfRangeValue 0 narrow first OutOfRangeValue 3 MeasurementError 4 domain errors reported through their attributes: record 3: OutOfRangeValue(field='value', value=99.0) -> field 'value', value 99.0 record 4: MissingField(field='value', value=None) -> field 'value', value None record 5: BrokenRecord(field='value', value='north') -> field 'value', value 'north' record 7: OutOfRangeValue(field='value', value=-80.0) -> field 'value', value -80.0 record 8: BrokenRecord(field='value', value=None) -> field 'value', value None record 10: MissingField(field='value', value=None) -> field 'value', value None record 12: OutOfRangeValue(field='value', value=60.1) -> field 'value', value 60.1
Reading the Numbers
4 of the twelve records are successful, 7 are domain errors, 1 is a program defect. Four forms split this distribution in different ways.
Three names and domain root give the same result: 7 handled, 0
swallowed defects, 1 escaped. The two are indistinguishable — but one writes 3
names, the other 1. This is exactly what the root pays for: as the domain grows, new
concrete classes will descend from the root, and no clause written as except MeasurementError will need to change. A clause counting three names has to be updated
with every new class.
Borrowed base handles only 3 errors and lets 5 records escape. Writing
except ValueError catches OutOfRangeValue, but MissingField and BrokenRecord do
not fall into it. A borrowed base is not a group name; each concrete class’s own
borrowed base is separate, and there is no shared one among them. The domain’s group
name is only the root.
Broad catching lets 0 records escape — and swallows 1 program defect. The record whose coefficient field was written with the wrong type is handled as if it were a domain error. The previous lesson’s finding takes concrete shape here: what hides the defect is not the defect itself, it is the clause that covers it.
The clause order table gives the sharpest number. When the root is written first,
the MeasurementError clause runs 7 times, the OutOfRangeValue clause 0 times.
The second clause is written, its syntax is correct, it raises no warning, and it never
runs — because the first matching clause wins, and the root already covers everything.
When the order is reversed, the narrow clause gets 3, the root clause 4; the
total is still 7. Catch clauses are written narrow to broad; the reverse
silently produces dead code.
The last dump shows what the attribute earns. Every line gives the field name and the
value without parsing a message; code reading the difference between -80.0 and
60.1 works with a number, not a string.
Translation at the Boundary
The root’s second job does not show up in the table, but it stands in the measurement’s
code. The process function tries to convert the raw value with float, and that call
can raise either ValueError or TypeError depending on the data’s shape. Neither of
these two classes is the domain’s own class; both are the conversion layer’s classes.
The code catches them and raises BrokenRecord in their place:
except (TypeError, ValueError): raise BrokenRecord("value", raw) from None
These three lines are a boundary translation. Code that knows what happened inside
speaks the domain’s language to the outside. The eighth record in the output is the
result of this: the value field is not a number, it is not a value at all, and float
produces a TypeError from it — but the class that escapes is BrokenRecord, and the
except MeasurementError clause catches it. Without the translation, that record would
fall into the same class as the one set apart as a “program defect” in the previous
section, and the two could not be told apart.
The previous lesson’s chain measurement also gives the cost of the from None used
here: because the context is suppressed, someone looking from outside cannot see the
TypeError. This is a deliberate choice — the conversion detail does not leak outside
the domain layer. If keeping the detail is wanted, from is written and the chain
becomes two; the decision is which side of the boundary gets the information.
Translation has a limit too. The TypeError in the coefficient field was not
translated, because that error does not come from the conversion layer, it comes from
the code that builds the record. Translating a program defect into the domain’s
class would make it look like something that can be handled.
Summary
- A custom exception class’s place in the hierarchy is not a matter of style; it decides who will catch it.
- Five classes and six catches produce 30 pairs, 13 of which match; the domain root covers 4 classes while it does not cover the class that does not descend from it.
- A borrowed base gives the class meaning and, in exchange, scope: 2 of four domain
classes leak into a foreign
except ValueErrororexcept KeyErrorclause. - The domain root handles 7 errors with 1 name; the clause counting three names gives the same result with 3 names, the borrowed base catches only 3 errors, the broad clause swallows 1 program defect.
- Clause order must go narrow to broad: when the root is written first, the narrow clause runs 0 times and silently becomes dead code.
Next Step
This lesson’s twelve records sat ready in memory; the value field was already a
string, and the only problem was converting it to a number. When records come from a
file, one more layer gets inserted: what sits in a file is neither a string nor a
number, it is bytes. Converting bytes to a string is an encoding decision, and if
that decision is wrong, the error never reaches the domain layer at all — it shows up
while reading. The next lesson measures what the same bytes give in text mode versus
binary mode, and exactly where a wrong encoding produces its exception.
To keep your progress and take notes, Log in
My notes
Log in to take notes.