Your checkout code calls a payment client to charge a card. When the charge fails, what you do next depends on why. A declined card needs a message to the customer. A busy gateway means "try again in a few seconds". A bad API key means someone on your team has to fix the config. A bug in your own code should crash, so that you see it.
A good client tells you why by raising a different exception class for each kind of failure:
class PaymentError(Exception):
pass
class CardDeclinedError(PaymentError):
pass
def charge(card: str) -> str:
if card == "expired":
raise CardDeclinedError("card expired")
if card == "closed":
raise PaymentError("merchant account closed")
return f"paid, receipt r-{card}"
for card in ["4242", "expired", "closed"]:
try:
print(card, "->", charge(card))
except CardDeclinedError:
print(card, "-> declined, ask for another card")
except PaymentError as exc:
print(card, "-> failed:", exc)
4242 -> paid, receipt r-4242
expired -> declined, ask for another card
closed -> failed: merchant account closed
An except clause catches every subclass
CardDeclinedError is a subclass of PaymentError, so a CardDeclinedError
is also a PaymentError. An except clause catches its own class and every
subclass of it. Here except PaymentError would catch both errors.
Put the most specific clause first
Python checks the except clauses from top to bottom and runs only the
first one that matches. Swap the two clauses above, and the expired card
prints failed: card expired. The CardDeclinedError clause never runs,
because except PaymentError matches first. List the most specific class
first and the broadest class last.
Group errors by what the caller does next
When you write the client, arrange its errors into a family, a tree of classes with one base. Python's documentation calls such a family an exception hierarchy. The next two exercises build on this one:
class PaymentError(Exception):
pass
class TransientPaymentError(PaymentError):
def __init__(self, message: str, *, retry_after: float) -> None:
super().__init__(message)
self.retry_after = retry_after
class PermanentPaymentError(PaymentError):
pass
class CardDeclinedError(PermanentPaymentError):
def __init__(self, message: str, *, decline_code: str) -> None:
super().__init__(message)
self.decline_code = decline_code
class ConfigurationError(PaymentError, ValueError):
pass
Each class matches a decision the caller makes:
TransientPaymentErrormeans the failure is temporary, such as a busy gateway. The same request may succeed later.PermanentPaymentErrormeans sending the same request again fails the same way. The customer has to change something, such as the card.CardDeclinedErroris a permanent failure that says why the bank said no.ConfigurationErrormeans your own setup is wrong, such as a bad API key.
A caller can now choose how much to catch. except CardDeclinedError catches
one case, and except PermanentPaymentError catches a category.
except PaymentError catches everything the client raises on purpose.
Start from the decisions your callers make. Another library might need classes for a missing record or a denied permission. Add a class when callers need to tell two failures apart, not for every message.
Put the details in attributes
A caller reads exc.retry_after or exc.decline_code instead of picking the
value out of the message. The message is for people, and the attributes are
for code. Each constructor passes the message to super().__init__(message),
so str(exc) still returns it. The * makes the extra field keyword-only,
so a call reads CardDeclinedError("declined", decline_code="expired_card").
Check your understanding Should the payment client catch every exception inside it and raise PaymentError instead? Show answer Hide answer
No. A bug in the client, such as an AttributeError, would then look like a routine payment failure, and the caller's except PaymentError would hide it. Translate only the failures you document, such as a timeout from the HTTP library the client uses. Let everything else reach the caller.
Add a built-in base only to keep old handlers working
ConfigurationError inherits from both PaymentError and ValueError, so
except ValueError catches it too. That helps when an older version of the
client raised ValueError for a bad API key and callers already catch it.
For an exception with no such history, use one base class. Python refuses
some pairs of built-in bases. class Broken(OSError, StopIteration) raises
TypeError as soon as Python runs it.
Your exception classes are part of your API
If you rename a class or move it to another place in the family, a caller's
except clause can stop matching. Test which class each public function
raises and which attributes it sets. When you turn a library's error into
your own, keep the original attached with raise ... from. Keep the Original Error with raise from, later in this track, explains how.
Next, in Catch Errors by Category, you get a family like this one and write
the except clauses in the right order.