CMPUT-274 · Lesson 8 of 12 · 31 min

Error handling and program robustness across paradigms

Every non-trivial program meets situations it cannot handle normally: a file is missing, input is malformed, a network request times out, a value is out of range. How a language represents and propagates these failures is a paradigm choice with large consequences for robustness. This lesson compares four approaches: special return values and error codes, as in C; exceptions, as in Python and Java; result and option types, as in Rust and functional languages; and defensive techniques that cut across paradigms, such as validation at boundaries, assertions and contracts. The goal is to choose an approach that makes failures hard to ignore and easy to handle in the right place.

Kinds of failure

It helps to separate three kinds of failure before choosing a mechanism. Expected failures are part of normal operation: a user types a bad date, a lookup finds nothing, a file that may or may not exist is absent. Environmental failures come from outside the program and can happen at any time: the disk is full, the network drops, memory runs out. Bugs are mistakes in the program itself: an index out of range, a violated invariant, a null where an object was required. Expected failures should be handled close to where they occur. Environmental failures often must travel up to a level that can retry, report or give up. Bugs should be detected early and loudly, not quietly handled, because handling a bug usually hides it.

Three kinds of failure and how to respond to them
Kind of failureExampleBest response
ExpectedUser enters 31 February; key not in dictionaryHandle locally; often represent in the return type
EnvironmentalDisk full, timeout, permission deniedPropagate to a layer that can retry, report or degrade gracefully
BugInvariant violated, impossible branch reachedFail fast with a clear message; fix the code

Error codes and special values

The oldest approach returns a special value to signal failure. C functions return −1 or `NULL` and set the global `errno`, as you saw in CMPUT-201. Python's `str.find` returns −1 when the substring is absent, and `dict.get` returns `None`. The approach is simple and cheap, but it has two serious weaknesses. Nothing forces the caller to check, so errors are easy to ignore. And the special value can collide with a legitimate result: if a function computes a number that can be negative, −1 cannot mean failure.

Unchecked special values: −1 silently becomes a valid index, and None fails far from its source
text = "paradigms"
i = text.find("z")              # -1 means not found
print("index:", i, "| text[i] =", repr(text[i]))   # bug: -1 silently indexes the LAST char

prices = {"tea": 350}
p = prices.get("coffee")        # None means absent
try:
    print(p * 2)
except TypeError as err:
    print("later failure:", err)
Output
index: -1 | text[i] = 's'
later failure: unsupported operand type(s) for *: 'NoneType' and 'int'

The first line shows how quietly a special value can go wrong: −1 is a valid Python index meaning the last character, so the unchecked result produces a plausible but wrong answer instead of an error. The second failure happens at the multiplication, not at the lookup that caused it, which makes the bug harder to trace. Both problems come from the same source: the failure is encoded in an ordinary value that the type system and the reader can easily treat as a success.

Exceptions

Exceptions separate the error path from the normal path. When a function cannot do its job, it raises an exception, and control jumps up the call stack to the nearest handler for that kind of exception, skipping the intermediate code. If no handler exists, the program stops with a traceback. Exceptions cannot be ignored silently: an unhandled exception is visible. They also keep the main logic uncluttered, because intermediate functions need no error-checking code to pass failures upward.

A call stack with main at the bottom, then load_config, then read_file at the top. An exception raised in read_file travels down past load_config, which has no handler, to main, which catches it. Arrows show the normal calls going up and the exception unwinding down.
Stack unwinding: the exception skips frames without handlers until one catches it
An exception raised deep in read_file is handled two levels up; finally always runs
class ConfigError(Exception):
    """Raised when the configuration cannot be loaded."""

def read_file(path):
    files = {"app.cfg": "port=8080\nhost=example.org"}
    if path not in files:
        raise FileNotFoundError(path)
    return files[path]

def parse(text):
    try:
        return {k: v for k, v in (line.split("=", 1) for line in text.splitlines())}
    except ValueError as err:
        raise ConfigError("malformed line") from err       # keep the cause

def load_config(path):          # no error handling needed here
    return parse(read_file(path))

for path in ["app.cfg", "missing.cfg"]:
    try:
        cfg = load_config(path)
        print("loaded:", cfg)
    except FileNotFoundError as err:
        print("using defaults; not found:", err)
    finally:
        print("  (attempted", path + ")")
Output
loaded: {'port': '8080', 'host': 'example.org'}
  (attempted app.cfg)
using defaults; not found: missing.cfg
  (attempted missing.cfg)

Good exception handling follows a few rules. Catch specific exception types, not everything: a bare `except:` also catches keyboard interrupts and programming errors that should crash. Catch at the level that can do something useful, such as retry, substitute a default or report to the user; elsewhere, let exceptions pass. Use `raise ... from err` to wrap a low-level exception in a domain-specific one while keeping the original as the cause. And release resources with `finally` or, better, a `with` statement, which guarantees clean-up even when an exception passes through.

Worked problem

Choosing where to catch

A command-line tool reads a list of URLs from a file, downloads each page and saves a summary. Where should it catch (a) the file not existing, (b) one download timing out, (c) a KeyError caused by a bug in the summary code?

Show solution
  1. (a) The missing input file means the tool cannot do anything; catch it in main, print a clear message and exit with a non-zero status.
  2. (b) One timeout should not stop the other downloads; catch it inside the per-URL loop, log it and continue, perhaps after a retry.
  3. (c) A KeyError from a bug should not be caught by a general handler; let it crash with a traceback so that it is noticed and fixed.
  4. Catching everything at the top with except Exception and continuing would hide case (c).

Answer: Catch (a) at the top level, (b) inside the loop, and do not catch (c). Each handler sits at the level that knows what to do about that failure.

Exceptions have costs. They are invisible in function signatures in Python, so you must read documentation or code to know what a call can raise. Java tried to fix this with checked exceptions, which must be declared in a method's `throws` clause and handled by callers, but many programmers found them verbose and wrapped them in unchecked exceptions, and later languages such as C# and Kotlin did not adopt them. Exceptions also make control flow non-local, which can surprise readers and complicates reasoning about state: if an exception occurs halfway through updating several fields, the object may be left inconsistent unless the update is designed to be all or nothing.

Result and option types

Functional languages usually represent failure in the return type. An option type, called `Option` in Rust, `Maybe` in Haskell and `Optional` in Java, holds either a value or nothing. A result type, `Result<T, E>` in Rust and `Either` in Haskell, holds either a success value of type `T` or an error of type `E`. Because failure is part of the type, the compiler forces the caller to deal with both cases before using the value. Errors become ordinary data that can be passed, stored and transformed with the same tools as any other value.

Rust's Result type: the caller must match both success and each kind of error
use std::num::ParseIntError;

#[derive(Debug)]
enum AgeError {
    NotANumber(ParseIntError),
    OutOfRange(i64),
}

fn parse_age(s: &str) -> Result<u8, AgeError> {
    let n: i64 = s.trim().parse().map_err(AgeError::NotANumber)?;   // ? returns early on Err
    if (0..=130).contains(&n) {
        Ok(n as u8)
    } else {
        Err(AgeError::OutOfRange(n))
    }
}

fn main() {
    for input in ["42", " 7 ", "abc", "250"] {
        match parse_age(input) {
            Ok(age) => println!("{:>5?} -> age {}", input, age),
            Err(AgeError::NotANumber(e)) => println!("{:>5?} -> not a number ({})", input, e),
            Err(AgeError::OutOfRange(n)) => println!("{:>5?} -> {} is out of range", input, n),
        }
    }
}
Output
"42" -> age 42
" 7 " -> age 7
"abc" -> not a number (invalid digit found in string)
"250" -> 250 is out of range

The `?` operator is the key to making result types pleasant. Applied to a `Result`, it returns the success value if there is one, and otherwise returns the error from the enclosing function immediately. It gives the brevity of exceptions, since intermediate functions need only a single character to propagate a failure, while keeping the failure visible in every signature on the way up. The error type is also an algebraic data type, so a `match` on it can be checked for exhaustiveness, as in lesson 3.

A small Result type in Python: failures flow through a pipeline as values
from dataclasses import dataclass
from typing import Generic, TypeVar, Callable, Union

T = TypeVar("T"); U = TypeVar("U"); E = TypeVar("E")

@dataclass(frozen=True)
class Ok(Generic[T]):
    value: T
@dataclass(frozen=True)
class Err(Generic[E]):
    error: E

Result = Union[Ok[T], Err[E]]

def bind(r, f):                         # chain a step that may fail
    return f(r.value) if isinstance(r, Ok) else r

def parse_int(s):
    return Ok(int(s)) if s.strip().lstrip("-").isdigit() else Err(f"not a number: {s!r}")
def check_range(n):
    return Ok(n) if 0 <= n <= 130 else Err(f"out of range: {n}")
def to_months(n):
    return Ok(n * 12)

for s in ["42", "abc", "250"]:
    r = bind(bind(parse_int(s), check_range), to_months)
    match r:
        case Ok(months): print(f"{s!r}: {months} months")
        case Err(msg):   print(f"{s!r}: error, {msg}")
Output
'42': 504 months
'abc': error, not a number: 'abc'
'250': error, out of range: 250

The `bind` function chains steps that may fail: each step runs only if the previous one succeeded, and the first error passes through the rest untouched. This is the railway-oriented style: a success track and an error track, with each function able to switch to the error track. Python's ecosystem mostly uses exceptions, so this style is uncommon in Python libraries, but it is idiomatic in Rust, Haskell, Scala, F# and Swift, and it is a good fit wherever failures are expected and frequent, such as validating user input.

Comparing error-handling mechanisms
MechanismVisible in signature?Can be ignored silently?Propagation cost in codeTypical languages
Special value / error codeSometimesYesExplicit check after every callC, parts of Python
Unchecked exceptionNoNo (crashes if unhandled)NonePython, C#, Kotlin, JavaScript
Checked exceptionYes (throws clause)NoDeclare or catchJava
Option / Result typeYes (return type)No (must unwrap or match)Small (? in Rust, bind)Rust, Haskell, Swift, Scala

Validation, assertions and contracts

Some robustness techniques work in every paradigm. Validate inputs at the boundaries of your system, where data arrives from users, files or networks, and convert it into well-typed internal values once. Inside the boundary, code can then assume valid data. This is often summarized as parse, don't validate: rather than checking a string and passing the same string around, turn it into a value of a type that cannot be invalid, such as a `Date` or an `EmailAddress` object, so that later code never needs to recheck.

Assertions express assumptions that should never be false if the code is correct. `assert balance >= 0` documents an invariant and stops the program immediately if a bug breaks it. Assertions are for bugs, not for expected failures: Python removes them when run with the `-O` flag, so they must never be the only check on user input. Design by contract, developed by Bertrand Meyer for the Eiffel language, formalizes the idea with preconditions, which callers must satisfy, postconditions, which the function guarantees, and invariants, which every method preserves. These are the same notions you used in CMPUT-272 to prove programs correct.

Parse once at the boundary into a type that cannot be invalid; use assertions for internal assumptions
from dataclasses import dataclass
import re

@dataclass(frozen=True)
class Email:
    """An email address that has already been checked; invalid instances cannot exist."""
    value: str
    def __post_init__(self):
        if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[a-z]{2,}", self.value):
            raise ValueError(f"invalid email: {self.value!r}")

def send_welcome(to: Email) -> str:        # no need to re-validate: the type guarantees it
    return f"welcome mail queued for {to.value}"

def withdraw(balance, amount):
    assert amount > 0, "precondition: amount must be positive"
    new = balance - amount
    assert new >= 0, "postcondition: balance stays non-negative"
    return new

print(send_welcome(Email("ana@example.org")))
for raw in ["not-an-address"]:
    try:
        Email(raw)
    except ValueError as err:
        print("rejected at the boundary:", err)
print(withdraw(100, 30))
try:
    withdraw(20, 50)
except AssertionError as err:
    print("bug detected:", err)
Output
welcome mail queued for ana@example.org
rejected at the boundary: invalid email: 'not-an-address'
70
bug detected: postcondition: balance stays non-negative

Worked problem

Designing error handling for a parser

A function reads a CSV file of student marks and returns a dictionary from student ID to mark. Bad rows are common: missing marks, text in the mark column, duplicate IDs. Propose an error-handling design.

Show solution
  1. Bad rows are expected failures, so do not abort the whole file on the first one.
  2. Parse each row into either a valid record or an error describing the row number and problem: a Result per row.
  3. Collect the valid records into the dictionary and the errors into a list, and return both, for example as a pair.
  4. Reserve exceptions for environmental failures such as the file not existing, which the caller handles, and use assertions only for internal invariants such as no duplicate keys in the final dictionary after deduplication.

Answer: Return (records, errors), where each row was parsed into a success or an error value. Users see every problem in one pass, environmental failures still raise, and bugs still fail loudly.

Common mistakes

  • MistakeUsing a bare except: or except Exception: that swallows every error and continues.

    InsteadCatch specific exceptions at the level that can handle them, and let bugs crash with a traceback.

  • MistakeReturning a special value such as -1 or None without the caller checking it.

    InsteadMake absence explicit in the type, raise an exception, or return an option or result that must be examined.

  • MistakeUsing assert to validate user input.

    InsteadAssertions can be disabled with -O and are meant for bugs; validate input with explicit checks that raise or return errors.

  • MistakeLosing the original cause when converting a low-level exception.

    InsteadUse raise NewError(...) from err to keep the chain for debugging.

  • MistakeValidating the same data repeatedly throughout the program.

    InsteadParse it once at the boundary into a type whose existence guarantees validity.

Summary and key terms

  • Distinguish expected failures, environmental failures and bugs; each calls for a different response.
  • Special values and error codes are cheap but easy to ignore and can collide with real results.
  • Exceptions separate normal and error paths, propagate automatically and cannot be ignored silently, but are invisible in Python signatures.
  • Catch specific exceptions at the level that can act, chain causes with from, and clean up with finally or with.
  • Option and result types put failure in the type, forcing callers to handle it; Rust's ? operator keeps propagation concise.
  • Validate at boundaries and parse data into types that cannot be invalid.
  • Assertions and contracts state assumptions that hold if the code is correct and catch bugs early.
error code
A special return value that signals failure, such as -1 or NULL.
exception
An object representing an error that transfers control to the nearest matching handler up the call stack.
stack unwinding
Leaving successive function frames while an exception propagates to a handler.
checked exception
An exception that must be declared or handled, enforced by the compiler, as in Java.
option type
A type whose values are either a present value or nothing.
result type
A type whose values are either a success value or an error value.
exception chaining
Attaching the original exception as the cause of a new one.
assertion
A statement that a condition must be true, which stops the program if it is false.
precondition
A condition the caller must satisfy before calling a function.
postcondition
A condition the function guarantees on return, provided its preconditions held.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Why is `text.find(x)` followed by `text[i]` without a check dangerous in Python?

    Show answer

    find returns -1 when absent, and -1 is a valid index for the last character, so the bug produces a wrong value instead of an error.

  2. Write a function that converts a string to int and returns None on failure, with a type hint.

    Show answer

    `def to_int(s: str) -> int | None:` with `try: return int(s)` and `except ValueError: return None`.

  3. What does a finally block guarantee?

    Show answer

    Its code runs whether the try block completes normally, returns or raises an exception.

  4. Rewrite `f = open(p); data = f.read(); f.close()` so the file is always closed.

    Show answer

    `with open(p) as f: data = f.read()`.

  5. In Rust, what does `let n = s.parse::<i32>()?;` do if parsing fails?

    Show answer

    It returns the error from the enclosing function immediately, which must itself return a compatible Result.

  6. Give one advantage of Result types over unchecked exceptions and one disadvantage.

    Show answer

    Advantage: failures are visible in signatures and must be handled. Disadvantage: every function on the path must declare and propagate the result, which adds some ceremony.

  7. Why should a KeyError caused by a bug usually not be caught?

    Show answer

    Catching it hides the bug; letting it crash with a traceback makes it visible so it can be fixed.

  8. What is the danger of `python -O` for code that uses assert to check user input?

    Show answer

    -O removes assert statements, so the checks disappear and invalid input is accepted.

  9. State a precondition and a postcondition for a function that returns the square root of x.

    Show answer

    Precondition: x ≥ 0. Postcondition: the result r satisfies r ≥ 0 and r² is approximately x.

  10. What does parse, don't validate mean?

    Show answer

    Convert raw input once into a type that can only hold valid values, so later code can rely on validity without rechecking.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz