Skip to content

← Failure by Design step 2 of 18

Medium Primitives

Exception hierarchies as API design

Every library ships two APIs: the one people call, and the one they except. The second one is usually designed by accident.

When a payment client raises ValueError from one code path, KeyError from another and requests.HTTPError from a third, callers have exactly two options: catch Exception — swallowing their own bugs along with your failures — or couple themselves to your internals and break the day you swap HTTP libraries. Neither is acceptable, and both are your fault, not theirs.

The fix is one package-level base class, subclassed along the axis callers actually branch on. That axis is almost never “which internal module raised it”. It is recoverability: do I retry this, or do I stop and tell the user?

Two rules that carry most of the weight:

  • Structured attributes, not encoded messages. retry_after belongs on the exception as a float. The moment a caller has to regex your message text to find out how long to wait, your message text is a public API you did not know you had.
  • Inherit from a builtin as well, when it is honestly true. class ConfigurationError(YourLibError, ValueError) means every except ValueError handler your users already wrote keeps working. That is how you introduce a hierarchy into a shipped library without a breaking change.

Build the hierarchy

  • PaymentError(Exception) — the single class that means “anything this library raises”.
  • TransientPaymentError(PaymentError) — worth retrying. Constructor (message: str, *, retry_after: float); store retry_after as an attribute.
  • PermanentPaymentError(PaymentError) — retrying changes nothing.
  • CardDeclinedError(PermanentPaymentError) — constructor (message: str, *, decline_code: str).
  • ConfigurationError(PaymentError, ValueError) — the caller wired the client up wrong.

Then the classifier

def classify(status: int, body: Mapping[str, object]) -> PaymentError:

Rules, applied in this order:

  1. status < 400 — calling a classifier on a success response is a bug in the caller, and a bug in the caller is a ValueError. Return ConfigurationError.
  2. status == 429, or 500 <= status <= 599 — return TransientPaymentError. retry_after is body["retry_after"] when that value is an int or a float, converted to float; otherwise 1.0.
  3. status == 402 — return CardDeclinedError. decline_code is body["decline_code"] when that value is a str; otherwise "unknown".
  4. status is 401 or 403 — return ConfigurationError.
  5. anything else — return PermanentPaymentError.

Note that classify returns the error rather than raising it. That is deliberate: it keeps the function pure, and it is how you would want to unit test the mapping in a real client.

Then the probe

def solve(events: Sequence[Mapping[str, object]]) -> list[dict[str, object]]:

Each event is {"status": <int>, "body": <mapping>}. Classify it and append one record per event, in order, with exactly these keys:

key value
"type" type(error).__name__
"payment_error" isinstance(error, PaymentError)
"value_error" isinstance(error, ValueError)
"transient" isinstance(error, TransientPaymentError)
"permanent" isinstance(error, PermanentPaymentError)
"retry_after" the attribute if it is a transient error, else None
"decline_code" the attribute if it is a declined card, else None

Message strings are not asserted — the whole point is that callers branch on type and attribute, never on prose.

What the type checker is doing for you here

body: Mapping[str, object] hands you object, not Any. float(raw) on an object is an error until you narrow it with isinstance. That is the type system telling you the truth about a JSON body: you do not know what is in there, so check.

Likewise classify is declared to return PaymentError, so .retry_after does not exist as far as mypy is concerned until an isinstance narrows it — which is exactly the discipline you want callers to inherit.

Loading visualization…