Skip to content
← Back to explainers

Python explainer

Design Exceptions Your Callers Can Catch

About 4 min to read. Allow extra time to work through examples.

Catch errors by category with the most specific except clause first, and design an exception family whose classes and attributes tell callers what to do next.

This article uses Python. You can make it your practice mode to browse related content across the site.

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:

  • TransientPaymentError means the failure is temporary, such as a busy gateway. The same request may succeed later.
  • PermanentPaymentError means sending the same request again fails the same way. The customer has to change something, such as the card.
  • CardDeclinedError is a permanent failure that says why the bank said no.
  • ConfigurationError means 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

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.

step 2 of 20

Error Handling with Exceptions, Retries and Warnings

Up next Catch Errors by Category