Unit 5: Exception handling

ECAP776 6 min read

I. Orientation — Errors and Controlled Failure

Exception handling is Python’s mechanism for detecting and responding to runtime problems without forcing a program to terminate immediately. An exception is an object representing an abnormal condition, such as invalid input, division by zero, a missing file, or an unavailable dictionary key.

  • Governing principle: Separate normal program logic from error-response logic so that failures can be handled at an appropriate level.
  • Exception hierarchy: Exceptions are classes arranged in an inheritance hierarchy rooted primarily at BaseException.
    • Most application-level exceptions inherit from Exception.
    • Examples include ValueError, TypeError, KeyError, IndexError, FileNotFoundError, and ZeroDivisionError.
    • SystemExit, KeyboardInterrupt, and GeneratorExit inherit directly from BaseException and are generally not caught by ordinary application handlers.
  • Propagation: If an exception is not handled in the function where it occurs, Python passes it up through calling functions until a matching handler is found.
  • Stack unwinding: During propagation, Python exits active function calls and releases their local execution contexts.
  • Termination: If no matching handler exists, Python stops the program and prints a traceback showing the exception type, message, and call path.
  • Core constructs: Python uses try, except, else, finally, and raise to control exceptional situations.
  • Design convention: Exceptions should represent exceptional or invalid conditions, not replace ordinary decisions that are more clearly expressed using if, loops, or return values.

II. Catching Exceptions — Handling a Runtime Failure

Catching an exception means placing failure-prone code in a try block and defining an except block that handles a matching exception type.

A. Catching exceptions

A single exception handler allows a program to recover from one anticipated category of failure.

  • Basic syntax: Python executes the try suite first and enters the except suite only if the specified exception occurs.
PYTHON
try:
    result = 10 / divisor
except ZeroDivisionError:
    result = None
  • divisor is the number by which 10 is divided.
  • result receives the quotient when division succeeds and None when divisor is zero.
  • ZeroDivisionError is the exception class matched by the handler.
  • Capturing the object: The as clause binds the exception object to a name, making its message available.
PYTHON
try:
    age = int(user_input)
except ValueError as error:
    print(f"Invalid age: {error}")
  • user_input is the text being converted.
  • age stores the resulting integer.
  • error refers to the caught ValueError object.
  • Matching rule: An except clause matches both the named class and its subclasses. For example, except LookupError catches both KeyError and IndexError.
  • Protected region: Only statements that may produce the anticipated exception should normally be placed in try; a narrow block makes the source of failure clear.
  • else clause: An else suite runs only when the try suite finishes without an exception.
PYTHON
try:
    value = int(text)
except ValueError:
    print("Not an integer")
else:
    print(value * 2)
  • finally clause: A finally suite runs whether execution succeeds, an exception is caught, or an exception continues propagating. It is suitable for mandatory cleanup such as releasing a lock.
  • Bare handler risk: except: catches almost every exception derived from BaseException, including interruption and exit signals. Prefer a specific class or, when a broad application boundary is necessary, except Exception.
  • Scope limitation: A handler cannot repair corrupted state automatically; it must perform a meaningful action such as supplying a fallback, logging the failure, retrying safely, or allowing propagation.

B. Applications and limitations

Effective catching preserves useful behavior while avoiding the concealment of programming defects.

  • Appropriate recovery: Catch FileNotFoundError when a missing optional configuration file has a valid default.
  • Inappropriate suppression: An empty handler such as except ValueError: pass may hide malformed data and make later failures harder to diagnose.
  • Resource safety: Context managers are often clearer than manual cleanup. with open("data.txt") as file: closes the file even when processing raises an exception.
  • Traceback preservation: If the current layer cannot recover meaningfully, it should let the exception propagate rather than replacing it with an unrelated fallback.

III. Catching Multiple Exceptions — Distinguishing Failure Categories

A try statement may handle several exception types, either with separate responses or with one shared response.

A. Catching multiple exceptions

Multiple handlers allow error responses to reflect the precise kind of failure that occurred.

  1. Separate handlers: Use several except clauses when exception types require different actions.
PYTHON
try:
    item = records[index]
    number = int(item)
except IndexError:
    print("The index is outside the list")
except ValueError:
    print("The selected item is not an integer")
  • Concrete distinction: IndexError means records[index] failed, whereas ValueError means int(item) received unsuitable text.
  • Single selection: At most one matching except suite runs for a particular exception.
  • Ordering: Handlers are tested from top to bottom, so subclasses must appear before parent classes.
PYTHON
try:
    process()
except FileNotFoundError:
    print("Required file is missing")
except OSError:
    print("Another operating-system error occurred")
  • FileNotFoundError is a subclass of OSError; reversing these clauses would make the specific handler unreachable.
  1. Grouped handler: Use a tuple when several exceptions require exactly the same response.
PYTHON
try:
    value = numbers[position] / divisor
except (IndexError, ZeroDivisionError) as error:
    print(f"Calculation failed: {error}")
  • Tuple meaning: The handler runs if either IndexError or ZeroDivisionError occurs.

  • Shared object: error refers to whichever exception instance was raised.

  • Trade-off: Grouping reduces repetition but is unsuitable when recovery depends on the particular failure.

  • Broad final handler: A final except Exception as error may log unexpected application errors after specific handlers, but it should not silently continue when program state is uncertain.

  • No-match behavior: If none of the clauses match, the exception propagates normally.

  • else placement: Successful operations that could themselves raise unrelated exceptions should be moved into else, preventing an earlier handler from catching them accidentally.

B. Selection guidelines

Handler structure should communicate whether failures are equivalent or require distinct recovery.

  • Use separate clauses: Choose them when missing data, invalid data, and unavailable resources lead to different messages or fallback actions.
  • Use a tuple: Choose it when the same cleanup, conversion to a status value, or user-facing response applies to every listed type.
  • Avoid oversized tuples: Grouping unrelated exceptions may erase information needed for diagnosis.
  • Catch at the right level: Low-level code often lacks enough context to recover, while a service boundary may know whether to retry, reject input, or report failure.

IV. Raising Exceptions — Signalling Invalid Conditions

Raising an exception deliberately interrupts normal execution when code detects a condition that violates its requirements.

A. Raising exceptions

The raise statement creates or propagates an exception so that a suitable caller can handle it.

  • Explicit raising: Supply an exception instance containing a clear diagnostic message.
PYTHON
def set_percentage(value):
    if not 0 <= value <= 100:
        raise ValueError("percentage must be between 0 and 100")
    return value
  • value is the proposed percentage.
  • The condition defines the valid inclusive interval [0, 100].
  • ValueError indicates that the argument has an acceptable general type but an invalid value.
  • Class form: raise ValueError is legal because Python instantiates the class, but raise ValueError("message") communicates the violated rule more clearly.
  • Type choice: Use established built-in exceptions when their meanings fit.
    • TypeError: An argument has an inappropriate type.
    • ValueError: Its type is acceptable, but its value is invalid.
    • RuntimeError: A runtime condition has no more specific standard category.
    • NotImplementedError: A required operation is intentionally unsupported by a base implementation.
  • Re-raising: A bare raise inside an except suite propagates the currently handled exception while preserving its traceback.
PYTHON
try:
    save_record(record)
except OSError:
    log_failure(record)
    raise
  • Exception chaining: raise NewError(...) from original_error presents a higher-level meaning while preserving the direct cause.
PYTHON
try:
    port = int(text)
except ValueError as error:
    raise ConfigurationError("port must be an integer") from error
  • text is the configuration value.
  • port is the intended integer.
  • error is the original conversion failure.
  • ConfigurationError expresses the application-level problem.
  • Implicit context: Raising a new exception during handling automatically records the earlier exception as context; from makes the causal relationship explicit.
  • Control-flow effect: Statements following raise in the same execution path do not run unless the exception is caught and control later returns through another path.

B. Applications and limitations

Deliberate raising enforces contracts close to the point where a violation becomes known.

  • Input validation: Reject a negative quantity before it enters inventory calculations.
  • State validation: Raise an exception if an operation requires an authenticated session but none exists.
  • Message quality: State the failed requirement, such as "timeout must be positive", rather than a vague message such as "bad input".
  • Limitation: Do not catch and immediately re-raise without logging, translation, cleanup, or another concrete purpose; the extra handler adds noise.

V. Custom Exception — Representing Domain-Specific Failure

A custom exception is a user-defined class that gives application-specific failures a stable, meaningful identity.

A. Custom exception

Custom exceptions should normally inherit directly or indirectly from Exception, allowing ordinary application handlers to catch them safely.

  • Minimal definition: An empty subclass is sufficient when the class name carries the meaning.
PYTHON
class InsufficientFundsError(Exception):
    """Raised when an account cannot cover a withdrawal."""
  • Usage: Raise the custom type at the point where the domain rule is violated.
PYTHON
def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(
            f"requested {amount}, available {balance}"
        )
    return balance - amount
  • balance is the available amount.
  • amount is the requested withdrawal.
  • The exception identifies failure of the account-balance rule.
  • Structured data: A custom constructor can retain values for programmatic inspection.
PYTHON
class InsufficientFundsError(Exception):
    def __init__(self, requested, available):
        self.requested = requested
        self.available = available
        super().__init__(
            f"requested {requested}, available {available}"
        )
  • requested and available become attributes on the exception object.
  • super().__init__(...) initializes the standard exception message in Exception.args.
  • Exception family: Related errors can inherit from one application base class.
PYTHON
class PaymentError(Exception):
    pass

class InsufficientFundsError(PaymentError):
    pass

class PaymentDeclinedError(PaymentError):
    pass
  • Flexible handling: Callers may catch InsufficientFundsError specifically or catch PaymentError for all payment-domain failures.
  • Naming convention: Exception class names conventionally end in Error, clearly identifying their purpose.
  • Inheritance restriction: Avoid inheriting directly from BaseException; doing so may bypass ordinary except Exception application boundaries.

B. Applications and limitations

Custom types are most useful when callers need to distinguish a domain failure from generic implementation errors.

  • API clarity: ConfigurationError communicates more intent than exposing an internal KeyError.
  • Stable boundaries: A library can translate database or network exceptions into documented domain exceptions while retaining the cause with raise ... from ....
  • Useful attributes: Fields such as requested, available, or resource_id enable handlers to respond without parsing message text.
  • Avoid unnecessary classes: If ValueError precisely describes a small function’s invalid argument, a custom type may add complexity without improving handling.
  • Responsibility boundary: A custom exception identifies failure but does not determine recovery; the catching layer must still choose whether to retry, report, compensate, or terminate.