Skip to main content

Errors and Exception Boundaries

An exception reports that an operation could not fulfill its contract. Catch it only where the program can recover, translate it into a more useful abstraction, or add context before allowing it to continue upward.

def load_port(raw: str) -> int:
try:
port = int(raw)
except ValueError as error:
raise ConfigurationError(f"invalid port: {raw!r}") from error

if not 1 <= port <= 65_535:
raise ConfigurationError("port is outside the valid range")
return port

raise ... from error makes translation explicit while preserving the cause. Use from None only when suppressing lower-level context genuinely improves the public diagnostic.

Handling structure

Keep the try block narrow so the handler catches only the operation it is meant to handle:

try:
document = read_document(path)
except FileNotFoundError:
return default_document()
else:
return parse_document(document)

The else block runs only when the protected operation succeeds. finally runs whether control exits normally or through an exception and is appropriate for unavoidable cleanup; context managers are usually clearer for resources.

Catch specific subclasses. except Exception can be appropriate at a process, request, or worker boundary that logs and isolates unexpected failure, but it must not silently turn arbitrary bugs into success. KeyboardInterrupt, SystemExit, and other termination signals inherit directly from BaseException and normally should propagate.

Defining errors

Reuse built-in exceptions when their meaning fits. A library can expose a small domain hierarchy rooted in one public exception so callers may choose broad or narrow handling. Store useful structured attributes when callers need to inspect the failure; do not force them to parse the message.

Exceptions should describe what failed, not leak secrets, tokens, or complete sensitive payloads. Logging and re-raising the same exception at every layer creates duplicate noise; choose the boundary that owns the diagnostic.

Assertions are not validation

assert records an internal invariant for developers. Python can remove assert statements under optimization, so never use them to validate user input, permissions, configuration, or other required runtime conditions. Raise an appropriate exception instead.

Test the failure contract

import pytest

def test_load_port_rejects_out_of_range_value() -> None:
with pytest.raises(ConfigurationError, match="outside"):
load_port("70000")

Test the public exception type, relevant attributes, state preservation, and cleanup. Avoid asserting an entire traceback or unstable wording unless exact text is part of the external interface.

Source