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
BaseExceptioninstances; - 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
| Term | Meaning |
|---|---|
| exception | object representing an interrupted operation |
| traceback | recorded call path leading to failure |
| exception chaining | linking a new exception to its cause |
| suppression | preventing an exception from propagating or displaying |
| failure boundary | layer responsible for mapping or reporting errors |
| retryable | suitable for another attempt |
| idempotent | safe to repeat without unintended effects |
| exception group | multiple exceptions propagated together |
| compensation | action that reverses or mitigates partial work |
| taxonomy | organized 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
finallyblock 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
Noneresult. - 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:
- failure categories;
- exception hierarchies;
- narrow
tryblocks; elseandfinally;- re-raising;
- translation;
- explicit chaining;
- exception notes;
- structured payloads;
- retry semantics;
- exception groups;
- failure boundaries;
- exceptions versus result values.