Skip to content

← Error Handling with Exceptions, Retries and Warnings step 4 of 20

Medium Primitives

Build a Payment Error Hierarchy

In Catch Errors by Category, you caught errors from a payment family that someone else wrote. Now you build the family yourself. You also write a function that turns a failed charge into the right error. The gateway's answer is an HTTP status code and a response body, which solve gets as data.

error = classify(402, {"decline_code": "expired_card"})
type(error).__name__                      # "CardDeclinedError"
isinstance(error, PermanentPaymentError)  # True

Build the family

The starter has PaymentError(Exception). Add four classes:

  • TransientPaymentError(PaymentError), with the constructor (message: str, *, retry_after: float). Store the delay in self.retry_after.
  • PermanentPaymentError(PaymentError), with no extra fields.
  • CardDeclinedError(PermanentPaymentError), with the constructor (message: str, *, decline_code: str). Store the code in self.decline_code.
  • ConfigurationError(PaymentError, ValueError), so that except ValueError catches it too.

Each constructor passes the message to super().__init__(message). The wording is up to you, because the tests check only classes and attributes.

Pick the error for a response

Write classify(status: int, body: Mapping[str, object]) -> PaymentError. It returns the exception object and does not raise it. Apply the first rule that fits:

  1. If status is below 400, return a ConfigurationError. That status is not an error, so calling classify with it is a mistake in your setup.
  2. If status is 429, or 500 to 599 including both ends, return a TransientPaymentError. If body.get("retry_after") is an int or a float, convert it with float(). Otherwise use 1.0.
  3. If status is 402, return a CardDeclinedError. If body.get("decline_code") is a string, use it, even the empty string "". Otherwise use "unknown".
  4. If status is 401 or 403, return a ConfigurationError.
  5. For any other status, return a PermanentPaymentError.

For retry_after, a bool counts as an int, because bool is a subclass of int. So True becomes 1.0 and False becomes 0.0. Keep zero and negative delays as they are. A string such as "2" gets 1.0.

Report each result

Write solve(events: Sequence[Mapping[str, object]]) -> list[dict[str, object]]. Return one record per event, in the same order, or [] for no events.

  • Read event["status"], so a missing status raises KeyError. If the value is not an int, raise TypeError. A bool passes this check, so True counts as status 1 and gets rule 1.
  • If the event's body is a mapping, use it. If it is missing or has another type, use an empty dictionary.
  • Ignore any other keys.

Each record has 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 error's retry_after if it is transient, otherwise None
"decline_code" the error's decline_code if it is a declined card, otherwise None

isinstance matches parent classes too. So a declined card is permanent, and a ConfigurationError is both a PaymentError and a ValueError.