Files
python-patterns/patterns/error-handling.md
T
2026-06-01 21:42:05 +00:00

4.0 KiB

Error Handling

Use exception types to encode what callers can do next.

Why

Good Python libraries do not collapse every failure into RuntimeError or Exception. They shape errors around recovery boundaries:

  • one base type for “something in this subsystem failed”
  • narrower subtypes when callers need different recovery
  • structured fields when the branch depends on data, not wording

That gives callers a clean ladder:

  • catch broadly at subsystem boundaries
  • catch narrowly when retry/report/ignore differs
  • surface better API or CLI errors without parsing strings

The pattern

  1. Define a subsystem-level base exception.
  2. Add subtypes only when callers need different handling.
  3. Put structured context on the exception when branching depends on it.
  4. Translate internal failures at API, CLI, or transport boundaries.

When to use

Use this when:

  • a module or library exposes a public API
  • failure modes need different handling
  • an outer boundary must turn internal failures into user-facing errors
  • retry, ignore, and abort decisions differ by failure kind

When not to use

Do not build a hierarchy when:

  • the code is tiny and has one obvious failure mode
  • every failure is handled the same way
  • the only distinction is wording, not behavior
  • a normal return value like None is already the contract

Do not make callers parse exception text. If the distinction matters, make it a type or a field.

Good shape

class MailError(Exception):
    pass

class TemporaryMailError(MailError):
    pass

class PermanentMailError(MailError):
    pass

class MailRejected(PermanentMailError):
    def __init__(self, code: int, reason: str) -> None:
        super().__init__(reason)
        self.code = code
        self.reason = reason

Caller:

try:
    send_mail(message)
except TemporaryMailError:
    retry_later(message)
except PermanentMailError as exc:
    mark_failed(message, reason=str(exc))

Counterexamples

Stringly-typed branching

try:
    do_work()
except Exception as exc:
    if "timeout" in str(exc).lower():
        retry()

The recovery rule is hiding in fragile text matching.

One catch-all with no domain meaning

class AppError(Exception):
    pass

raise AppError("not found")
raise AppError("permission denied")
raise AppError("timeout")

Callers cannot branch meaningfully.

Boundary types leaking into the core

from fastapi import HTTPException

def charge_card(card: Card) -> Receipt:
    if card.expired:
        raise HTTPException(status_code=400, detail="expired card")

This couples domain logic to one transport. Raise a domain error here; translate to HTTP at the boundary.

Source signals

Stdlib / CPython

  • Lib/smtplib.py:69-71 defines SMTPException as the module-wide base type.
  • Lib/smtplib.py:88-100 defines SMTPResponseException and stores structured fields on the exception itself: smtp_code and smtp_error.
  • Lib/smtplib.py:102-125 adds subtype-specific payload like sender on SMTPSenderRefused and recipients on SMTPRecipientsRefused.

HTTPX

  • httpx/_exceptions.py:74-90 defines HTTPError as a broad catch point and explicitly documents it as useful around request + raise_for_status() flows.
  • httpx/_exceptions.py:107-125 narrows that into RequestError and TransportError for request-time failures.
  • httpx/_exceptions.py:132-160 further splits timeout handling into ConnectTimeout, ReadTimeout, WriteTimeout, and PoolTimeout.

Click

  • src/click/exceptions.py:35-65 defines ClickException with behavior, not just categorization: an exit code and a show() renderer.
  • src/click/exceptions.py:68-111 makes UsageError a narrower subtype with a different exit code and help-aware output.

Bottom line

If callers need different behavior, give them different exception types.

If callers need details, attach fields.

If an outer layer needs user-facing output, translate there instead of pushing boundary concerns through the whole codebase.