Skip to content
academia.sh

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; object is 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 the except clause performs, and it can be known in advance at the class level.
  • EF12 — A “foreign catch” is an except ValueError or except KeyError clause 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 ValueError or except KeyError clause.
  • 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.

Start typing to search.

↑↓ Esc navigate · open · close