Zum Hauptinhalt springen

Chapter 21 — Exceptions, Error Taxonomy, and Failure Boundaries

1. Opening problem

Consider:

def create_invoice(
order: Order,
) -> Invoice | None:
try:
return invoice_gateway.create(
order
)
except Exception:
return None

The function appears robust because it never crashes.

In reality, it destroys information.

None could mean:

  • the order is not invoiceable;
  • the customer address is incomplete;
  • the database is unavailable;
  • authentication failed;
  • a programming defect occurred;
  • the process was cancelled;
  • an invariant was violated.

A good exception strategy does not merely prevent crashes. It preserves meaning, protects boundaries, supports observability, and makes recovery intentional.


2. What an exception represents

An exception interrupts the normal control flow because an operation cannot complete as expected.

Examples:

int("not-a-number")

raises ValueError.

mapping["missing"]

raises KeyError.

open("missing.txt")

raises FileNotFoundError.

Exceptions are runtime objects containing information such as:

  • type;
  • message;
  • arguments;
  • traceback;
  • causal context;
  • explicit cause;
  • notes;
  • nested exceptions in an exception group.

Exceptions should communicate what failed and at which abstraction level.


3. Exception categories

A useful engineering taxonomy includes at least four categories.

3.1 Domain failures

Expected business outcomes that prevent an operation.

Examples:

  • insufficient balance;
  • order already cancelled;
  • unsupported state transition;
  • duplicate email;
  • expired reservation.
class DomainError(
Exception
):
pass


class InsufficientBalanceError(
DomainError
):
def __init__(
self,
*,
available_cents: int,
requested_cents: int,
) -> None:
super().__init__(
"Insufficient balance"
)
self.available_cents = (
available_cents
)
self.requested_cents = (
requested_cents
)

These errors may be mapped to user-facing or API responses.

3.2 Input and validation failures

External or caller-provided data is malformed.

Examples:

  • missing required field;
  • invalid date;
  • unsupported currency;
  • negative quantity.

Use precise exceptions or structured validation results.

3.3 Infrastructure failures

The domain operation may be valid, but a dependency failed.

Examples:

  • database unavailable;
  • network timeout;
  • object storage denied access;
  • message broker disconnected.

These failures may support retry, circuit breaking, or operational alerts.

3.4 Programming defects

Examples:

  • impossible branch reached;
  • attribute misspelling;
  • incorrect assumption;
  • invariant broken by internal code;
  • index or key errors not expected by the design.

Do not convert every defect into an ordinary business response.

Unexpected failures should remain visible.


4. Exception hierarchy design

Keep the hierarchy meaningful and shallow.

class ApplicationError(
Exception
):
pass


class DomainError(
ApplicationError
):
pass


class ValidationError(
ApplicationError
):
pass


class InfrastructureError(
ApplicationError
):
pass

Specific exceptions:

class DuplicateEmailError(
DomainError
):
pass


class PaymentGatewayTimeoutError(
InfrastructureError
):
pass

Benefits:

  • precise handling;
  • broad boundary handling where appropriate;
  • clear tests;
  • stable public contracts.

Avoid one class per message when no handling or data distinction exists.


5. Inherit from Exception

Application exceptions should normally inherit from Exception, directly or indirectly.

Do not inherit ordinary application errors directly from BaseException.

BaseException also includes control-flow-related exceptions such as:

  • KeyboardInterrupt;
  • SystemExit;
  • GeneratorExit.

Broadly catching BaseException can prevent normal process termination or generator cleanup.

Use:

except Exception:
...

only at a boundary where broad handling is intentional.

Even there, preserve the traceback and re-raise or report correctly.


6. Catch only what you can handle

Good:

try:
user = repository.find(
user_id
)
except DatabaseTimeoutError:
metrics.increment(
"user_lookup_timeout"
)
raise

The handler adds observability and preserves the failure.

Bad:

try:
user = repository.find(
user_id
)
except Exception:
user = None

The second version converts every failure into “not found.”

A handler should do at least one of these:

  • recover;
  • translate;
  • enrich;
  • retry;
  • compensate;
  • report;
  • deliberately suppress.

Otherwise, do not catch at that level.


7. else and finally

try:
value = parse_payload(
payload
)
except ValueError as error:
handle_invalid_payload(
error
)
else:
process(value)
finally:
release_temporary_state()

Use else for code that should run only if the try block succeeds.

Why not place process(value) inside try?

Because then its exceptions may accidentally be caught by a handler intended only for parsing.

Keep try blocks narrow.

finally runs whether the operation:

  • succeeds;
  • raises;
  • returns;
  • breaks;
  • continues.

It is for cleanup that must happen.


8. Do not return from finally

Dangerous:

def calculate() -> int:
try:
raise RuntimeError(
"failed"
)
finally:
return 42

The return suppresses the exception.

Similarly, control-flow statements inside finally can hide failures and make debugging extremely difficult.

Prefer cleanup without replacing the active outcome.


9. Re-raising

Preserve the current exception and traceback:

try:
operation()
except DatabaseError:
logger.exception(
"Database operation failed"
)
raise

Do not write:

except DatabaseError as error:
raise error

Although both raise the same object, bare raise is the standard way to preserve the active exception context and traceback semantics.


10. Exception translation

Infrastructure exception:

class DriverTimeoutError(
Exception
):
pass

Application-level translation:

class UserRepositoryTimeoutError(
InfrastructureError
):
pass
try:
row = driver.query(
statement
)
except DriverTimeoutError as error:
raise UserRepositoryTimeoutError(
"Timed out loading user"
) from error

The application should not leak every driver-specific exception through all layers.

Translation creates a stable abstraction boundary.


11. Explicit exception chaining

raise UserRepositoryTimeoutError(
"Timed out loading user"
) from error

The explicit cause appears in the traceback.

The new exception contains:

new_error.__cause__

When a new exception is raised during handling without from, Python records implicit context in:

new_error.__context__

Use explicit chaining when translating errors so the causal relationship is intentional.


12. Suppressing causal display

try:
value = mapping[key]
except KeyError:
raise ConfigurationError(
f"Missing setting: {key}"
) from None

from None suppresses display of the lower-level exception context.

Use it when:

  • the lower-level failure is an implementation detail;
  • the new message fully explains the problem;
  • hiding the traceback context does not reduce diagnosability.

Do not suppress useful causes merely to make tracebacks shorter.


13. Enriching exceptions with notes

An exception can receive notes:

try:
process_file(path)
except OSError as error:
error.add_note(
f"Customer import file: "
f"{path}"
)
raise

Notes preserve the original exception type while adding contextual information.

Useful contexts include:

  • record identifier;
  • batch name;
  • operation stage;
  • safe request identifier;
  • retry attempt;
  • filename.

Do not add secrets or personal data to exceptions that may be logged.


14. Exception payloads

Custom exceptions can expose structured fields:

class InventoryShortageError(
DomainError
):
def __init__(
self,
*,
product_id: str,
requested: int,
available: int,
) -> None:
self.product_id = (
product_id
)
self.requested = requested
self.available = available

super().__init__(
"Insufficient inventory"
)

Handlers can map fields without parsing messages.

Avoid business logic based on exception-string matching.


15. Logging exceptions

Inside an exception handler:

logger.exception(
"Failed to create invoice"
)

typically records the active traceback.

Avoid duplicate logging at every layer.

A common strategy:

  • inner layers translate or enrich;
  • one operational boundary logs the unhandled failure;
  • domain failures may be logged at lower severity or not at all;
  • request identifiers are attached through structured context.

Repeated logs for the same traceback create noise.


16. Retryability belongs to semantics

Do not retry based only on a broad exception superclass.

Potentially retryable:

  • transient network timeout;
  • temporary service unavailable;
  • optimistic concurrency conflict in a controlled operation.

Usually not retryable:

  • validation error;
  • authentication error;
  • unsupported state;
  • programming defect;
  • non-idempotent operation without safeguards.

A typed classification can help:

class RetryableError(
InfrastructureError
):
pass

But retryability can also depend on:

  • operation;
  • attempt count;
  • idempotency key;
  • elapsed time;
  • downstream contract.

17. Exception groups

Concurrent work can fail in several independent places.

errors = [
ValueError(
"invalid user"
),
TimeoutError(
"payment timed out"
),
]

raise ExceptionGroup(
"Batch processing failed",
errors,
)

An exception group contains multiple exceptions.

It can be nested.

Exception groups are especially relevant to structured concurrency, where several child tasks may fail.


18. Handling exception groups with except*

try:
run_batch()
except* ValueError as group:
for error in group.exceptions:
report_validation_error(
error
)
except* TimeoutError as group:
for error in group.exceptions:
schedule_retry(
error
)

except* matches relevant subgroups while unhandled exceptions continue propagating.

It is not the same as a normal except.

Do not flatten every concurrent failure into one message. Preserve individual errors where recovery differs.


19. Domain results versus exceptions

A predictable alternative outcome may be represented as a result value.

@dataclass(frozen=True)
class Accepted:
order_id: str


@dataclass(frozen=True)
class Rejected:
reason: str


type OrderResult = (
Accepted
| Rejected
)

Use a result value when:

  • both outcomes are expected;
  • callers should branch explicitly;
  • failure does not require stack unwinding;
  • the alternative is part of the normal domain flow.

Use an exception when:

  • the operation cannot fulfill its contract;
  • the caller often cannot recover locally;
  • stack unwinding is useful;
  • a cross-cutting boundary should handle it.

Do not force one universal rule.


20. API boundary

def handle_create_order(
request: Request,
) -> Response:
try:
command = parse_command(
request.json
)
order = service.create(
command
)
except ValidationError as error:
return validation_response(
error
)
except DuplicateOrderError as error:
return conflict_response(
error
)
except InfrastructureError:
logger.exception(
"Order creation unavailable"
)
return service_unavailable_response()

return created_response(
order
)

The boundary maps internal failure meanings to transport responses.

Avoid catching a plain Exception and returning status 400 for every failure.

Unexpected exceptions should normally be reported as internal failures.


21. Cleanup and exceptions

Manual pattern:

resource = acquire()

try:
use(resource)
finally:
resource.release()

Prefer a context manager where ownership is reusable:

with acquire_resource() as resource:
use(resource)

Exceptions and context managers work together. The next chapter develops this relationship.


22. TypeScript comparison

TypeScript and JavaScript use throw and try/catch/finally.

Key differences:

  • JavaScript can throw any value;
  • Python conventionally raises BaseException instances;
  • Python has exception chaining with from;
  • Python has exception notes;
  • Python supports exception groups and except*;
  • Python's context-manager protocol integrates cleanup with exceptions.

TypeScript often models expected failures with discriminated unions. Python can also use result unions, dataclasses, or exceptions depending on semantics.


23. Common mistakes

Broad catch and fallback

Destroys the distinction between absence and failure.

Message parsing

Use structured exception attributes.

Logging and re-wrapping everywhere

Creates duplicate noise.

Raising a new error without chaining

Loses causal clarity.

Returning from finally

Suppresses failures.

Catching BaseException

Interferes with control flow.

Retry every exception

Can repeat defects or unsafe operations.

Treating every domain rejection as exceptional

A result value may be clearer.

Leaking low-level exceptions

Translate at abstraction boundaries.


24. English vocabulary

TermMeaning
exceptionobject representing an interrupted operation
tracebackrecorded call path leading to failure
exception chaininglinking a new exception to its cause
suppressionpreventing an exception from propagating or displaying
failure boundarylayer responsible for mapping or reporting errors
retryablesuitable for another attempt
idempotentsafe to repeat without unintended effects
exception groupmultiple exceptions propagated together
compensationaction that reverses or mitigates partial work
taxonomyorganized classification system

Useful sentences:

  • “The handler converts an infrastructure failure into a false not-found result.”
  • “The exception should be translated at the repository boundary.”
  • “The original cause is preserved through explicit chaining.”
  • “This operation is not safe to retry without an idempotency key.”
  • “The task group may raise several independent failures.”
  • “The finally block must not replace the active exception.”

25. Speaking task

Explain for ten minutes:

How should a production Python service classify and handle failures?

Include domain errors, infrastructure errors, defects, translation, chaining, retries, and boundaries.


26. Writing task

Write a 450-word incident review for a service that returned “user not found” whenever any database exception occurred.


27. Exercises

Exercise 1

Design an exception hierarchy for order processing.

Exercise 2

Translate a database-driver timeout into an application exception with explicit chaining.

Exercise 3

Add safe contextual notes while importing records.

Exercise 4

Create and handle an exception group containing validation and timeout failures.

Exercise 5

Refactor a broad except Exception: return None function.

Exercise 6

Choose exceptions or result values for five domain scenarios.


28. Complete solutions

Solution 1

class OrderError(
Exception
):
pass


class OrderValidationError(
OrderError
):
pass


class OrderConflictError(
OrderError
):
pass


class OrderInfrastructureError(
OrderError
):
pass


class DuplicateOrderError(
OrderConflictError
):
def __init__(
self,
order_id: str,
) -> None:
self.order_id = order_id
super().__init__(
f"Order already exists: "
f"{order_id}"
)

Solution 2

try:
row = driver.fetch_one(
statement,
parameters,
)
except DriverTimeoutError as error:
raise OrderRepositoryTimeoutError(
"Timed out loading order"
) from error

Solution 3

for line_number, record in enumerate(
records,
start=1,
):
try:
import_record(record)
except ValueError as error:
error.add_note(
f"Import line: "
f"{line_number}"
)
raise

Do not include secret field values in the note.

Solution 4

def run_batch() -> None:
raise ExceptionGroup(
"Batch failed",
[
ValueError(
"Invalid email"
),
TimeoutError(
"Remote service timeout"
),
],
)


try:
run_batch()
except* ValueError as group:
for error in group.exceptions:
print(
"Validation:",
error,
)
except* TimeoutError as group:
for error in group.exceptions:
print(
"Retryable:",
error,
)

Solution 5

Before:

def find_user(
user_id: int,
) -> User | None:
try:
return repository.find(
user_id
)
except Exception:
return None

After:

def find_user(
user_id: int,
) -> User | None:
try:
return repository.find(
user_id
)
except DriverTimeoutError as error:
raise UserRepositoryTimeoutError(
"Timed out loading user"
) from error

A genuine missing user remains None; infrastructure failure remains a failure.

Solution 6

  • Duplicate email during registration: domain exception or explicit rejection result, depending on API style.
  • Optional cache miss: normal None result.
  • Database timeout: exception.
  • Invalid external request: structured validation failure.
  • Unsupported command selected by internal code despite exhaustive typing: programming error.

29. Chapter checkpoint

You should now be able to explain:

  1. failure categories;
  2. exception hierarchies;
  3. narrow try blocks;
  4. else and finally;
  5. re-raising;
  6. translation;
  7. explicit chaining;
  8. exception notes;
  9. structured payloads;
  10. retry semantics;
  11. exception groups;
  12. failure boundaries;
  13. exceptions versus result values.