Programming with PythonFiles, Errors, and Object-Oriented Python › Day 66

Hands-on lab — Day 66: Exceptions and Error Handling Strategy

Commands

Setup

cd labs/sections/programming-with-python/day-066-exceptions-and-error-handling-strategy
python3 --version

Run

python3 examples/raw_triage.py examples/samples/no-such-file.jsonl
python3 examples/raw_triage.py examples/samples/bad-severity.jsonl
python3 examples/raw_triage.py examples/samples/missing-field.jsonl
python3 examples/swallowing.py examples/samples/no-such-file.jsonl
python3 examples/triage.py examples/samples/intake.jsonl
python3 examples/triage.py examples/samples/missing-field.jsonl
python3 examples/triage.py examples/samples/no-such-file.jsonl
python3 examples/dispatch_demo.py

Test

bash tests/run_tests.sh

File tree

examples/dispatch_demo.py
examples/raw_triage.py
examples/samples/bad-severity.jsonl
examples/samples/intake.jsonl
examples/samples/missing-field.jsonl
examples/swallowing.py
examples/triage.py
expected-output/FIELDS.md
expected-output/log-sample.txt
expected-output/sample-run.txt
expected-output/test-run.txt
expected-output/tracebacks.txt
metadata.yml
README.md
requirements/README.md
security.md
starter/traceback-notes.md
starter/triage.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 066 lab — Failing Well

Lesson

Purpose

You are handed a small file-processing script that never crashes — and that is the problem. examples/swallowing.py wraps its whole body in a bare except: and a pass, so a missing file, a bad number, and a missing field all come out the same way: done, exit code 0, and a total that is quietly wrong. In this lab you rebuild that program's error strategy from the ground up.

You will reproduce and read three real tracebacks; replace the bare handler with narrow ones plus try/except/else/finally; add a custom exception and re-raise with from so the chained traceback keeps the original cause; write a @retry decorator with exponential backoff against a deliberately flaky operation; and log every failure with logging.exception so the full traceback survives in a file even though the user only sees one clean line.

The whole lab is deterministic: no randomness, no network, no clock reading. Run it twice and you get identical output both times.

Learning objectives

  • Read a traceback frame by frame and name the exception type, the message, and the line that raised it.
  • Replace a bare except: with handlers that catch only what you can actually act on, ordered most-specific-first.
  • Use else for the code that must run only when nothing raised, and finally for the cleanup that must run on every path out.
  • Define a minimal custom exception and raise it with raise ... from err, and see the difference __cause__ makes in the printed traceback.
  • Decide where to handle: reject a bad record in the loop, let a missing file propagate to the boundary, and fail fast when the job cannot continue.
  • Write a @retry decorator with backoff, retry only errors that a later attempt might survive, and give up with the cause preserved.
  • Log an exception properly with logging.exception and verify what landed in the log file.

Prerequisites

  • The Day 66 lesson — read it first; it walks this exact program.
  • Day 64 and Day 65: reading and writing files, and JSON in the real world.
  • Day 58: functions returning functions (closures and decorators) — the @retry helper is a plain decorator factory.
  • Day 63: designing a small program well (pure core, thin shell).
  • A text editor and a terminal. Classes are not assumed: the two custom exceptions are provided, and the two-line class MyError(Exception) form is all this lab uses. Classes proper are tomorrow, Day 67.

Supported operating systems

  • macOS — fully supported (captured on macOS, Apple Silicon, Python 3.14.0, bash 3.2.57).
  • Linux — fully supported (any distribution with Python 3 and bash).
  • Windows — use WSL and follow the Linux path. Native Windows Python raises identical exceptions, but tracebacks print backslash paths and the test runner needs bash.

Hardware requirements

Any computer that runs Python 3. The lab reads a few dozen bytes of JSON and writes a small log file. No special memory, disk, or GPU.

Required software

  • python3 (3.8 or newer; tested on 3.14.0).
  • bash for the test runner (preinstalled on macOS and Linux).
  • Standard library only: json, logging, sys, time, traceback. Nothing to install. See requirements/README.md.

Free and open-source options

Everything here is free and open source: Python, bash, and the standard library. Exception handling is a language feature and logging ships with Python, so there is nothing to buy and no account to create. The lesson's Alternatives section covers the paid hosted error-trackers you may meet at work; none of them is needed here, and none is used.

Installation

None beyond Python itself:

cd labs/sections/programming-with-python/day-066-exceptions-and-error-handling-strategy
python3 --version   # confirm Python 3.8+

File structure

day-066-exceptions-and-error-handling-strategy/
├── README.md                       ← you are here
├── metadata.yml                    ← machine-readable lab metadata
├── starter/
│   ├── triage.py                   ← YOUR working file (3 numbered exercises)
│   └── traceback-notes.md          ← Exercise 0: record and read three tracebacks
├── examples/
│   ├── raw_triage.py               ← no handling at all — the traceback generator
│   ├── swallowing.py               ← the bare-except anti-pattern (the "before")
│   ├── triage.py                   ← the reference strategy (the "after")
│   ├── dispatch_demo.py            ← @retry recovering, and giving up with chaining
│   └── samples/
│       ├── intake.jsonl            ← five clean records
│       ├── bad-severity.jsonl      ← one record whose severity is "high" (ValueError)
│       └── missing-field.jsonl     ← one record with no severity field (KeyError)
├── tests/
│   └── run_tests.sh                ← behaviour checks: types, chaining, exit codes, log
├── expected-output/
│   ├── tracebacks.txt              ← the three real tracebacks (paths shortened)
│   ├── sample-run.txt              ← a real captured session
│   ├── log-sample.txt              ← what logging.exception wrote
│   ├── test-run.txt                ← a real captured test run
│   └── FIELDS.md                   ← required behaviour on every platform
├── requirements/
│   └── README.md                   ← dependency statement (Python 3 only)
├── troubleshooting.md
└── security.md

How to run

From this directory:

## 0. Reproduce three real failures and read the tracebacks.
python3 examples/raw_triage.py examples/samples/intake.jsonl
python3 examples/raw_triage.py examples/samples/no-such-file.jsonl
python3 examples/raw_triage.py examples/samples/bad-severity.jsonl
python3 examples/raw_triage.py examples/samples/missing-field.jsonl

## 1. See what the bare except costs: the same failures, reported as success.
python3 examples/swallowing.py examples/samples/no-such-file.jsonl ; echo "exit: $?"
python3 examples/swallowing.py examples/samples/bad-severity.jsonl ; echo "exit: $?"

## 2. Now the rebuilt program. Clean input, then each dirty input.
python3 examples/triage.py examples/samples/intake.jsonl        ; echo "exit: $?"
python3 examples/triage.py examples/samples/missing-field.jsonl ; echo "exit: $?"
python3 examples/triage.py examples/samples/no-such-file.jsonl  ; echo "exit: $?"

## 3. Read the log the run left behind: one clean line for the user,
##    the whole chained traceback for you.
python3 examples/triage.py examples/samples/bad-severity.jsonl triage.log
cat triage.log

## 4. Watch @retry back off and recover, then exhaust its attempts
##    and re-raise with the cause preserved.
python3 examples/dispatch_demo.py

## 5. Your turn: fill in starter/traceback-notes.md, then the three
##    exercises in starter/triage.py, then run your version.
python3 starter/triage.py examples/samples/missing-field.jsonl

## 6. Check your work.
bash tests/run_tests.sh

What the commands do

  • python3 examples/raw_triage.py <file> — a reader with no handling whatsoever. On a good file it prints a total; on each bad file it lets the exception escape, so Python unwinds the stack and prints a real traceback. The three files were chosen to raise FileNotFoundError, ValueError, and KeyError respectively.
  • python3 examples/swallowing.py <file> — the same work wrapped in except: pass. It always prints done and always exits 0, even for a file that does not exist. echo "exit: $?" shows you the exit code so you can see the lie.
  • python3 examples/triage.py <file> [logfile] — the rebuilt program. A bad record is rejected by name and logged, and the rest of the file is still processed (exit 0). A bad path is not handled in the loader at all; it propagates to main(), which reports it and exits 1. With no argument it prints usage and exits 2.
  • cat triage.log — the log logging.exception wrote: the message, the full traceback, and the chaining line that connects the RecordError you raised to the ValueError that caused it.
  • python3 examples/dispatch_demo.py — calls a flaky operation that fails twice and succeeds on the third attempt (you see the 0.05 s and 0.10 s backoff), then an operation that never succeeds, so @retry gives up and raises DispatchError from the last ConnectionError.
  • bash tests/run_tests.sh — 29 checks (48 once your starter is complete): exception types, __cause__ chaining, band counting, retry behaviour and its delays, end-to-end exit codes, and the contents of the log file. Exits 0 only if every check passes.

Expected output

See expected-output/sample-run.txt and expected-output/tracebacks.txt — real captured runs. The rebuilt program on a file with a missing field:

$ python3 -u examples/triage.py examples/samples/missing-field.jsonl ; echo "exit: $?"
admitted 2 record(s), rejected 1
immediate  1
urgent     1
routine    0
rejected: line 2: missing field 'severity'
attempt 1 failed (ward system unavailable (call 1)); retrying in 0.05s
attempt 2 failed (ward system unavailable (call 2)); retrying in 0.10s
dispatched 2 record(s) to the ward system
exit: 0

And the same program on a file that is not there:

$ python3 -u examples/triage.py examples/samples/no-such-file.jsonl ; echo "exit: $?"
error: no such intake file: examples/samples/no-such-file.jsonl
exit: 1

Your tracebacks will not match ours character for character, and they are not meant to. A traceback prints the absolute path of every file in the stack, so yours show wherever you cloned this repository; the captured files shorten the lab directory to <lab> for readability. What must match is the exception type, the message, the order of the frames, and the exit code. expected-output/FIELDS.md states exactly that required behaviour, and also explains the python3 -u used when capturing (it keeps stdout and stderr in terminal order when output is redirected to a file).

Validation steps

  1. python3 examples/raw_triage.py examples/samples/no-such-file.jsonl ends with FileNotFoundError: [Errno 2] No such file or directory: ... and exits 1.
  2. The same command on bad-severity.jsonl ends with ValueError: invalid literal for int() with base 10: 'high'; on missing-field.jsonl it ends with KeyError: 'severity'.
  3. python3 examples/swallowing.py examples/samples/no-such-file.jsonl; echo $? prints done and then 0 — the failure you just saw, made invisible.
  4. python3 examples/triage.py examples/samples/intake.jsonl prints admitted 5 record(s), rejected 0 and exits 0.
  5. python3 examples/triage.py examples/samples/missing-field.jsonl; echo $? prints rejected: line 2: missing field 'severity' to standard error, still summarizes the other two records, and exits 0.
  6. python3 examples/triage.py examples/samples/no-such-file.jsonl; echo $? prints error: no such intake file: ... and exits 1.
  7. python3 examples/triage.py examples/samples/bad-severity.jsonl triage.log then cat triage.log shows ERROR: rejected record on line 2, a full traceback, the line The above exception was the direct cause of the following exception, and the original ValueError.
  8. python3 examples/dispatch_demo.py shows two backoff lines (0.05s, 0.10s), then a success, then a chained DispatchError.
  9. starter/traceback-notes.md is filled in: three tracebacks pasted and all the questions answered.
  10. Your completed starter/triage.py behaves identically to the reference.
  11. bash tests/run_tests.sh ends with 0 failure(s). and exits 0.

Tests

bash tests/run_tests.sh

While the starter is unfinished the suite tests the reference strictly and your starter structurally, ending in 29 checks, 0 failure(s). Once you have completed all three exercises (no NotImplementedError and no bare except: left), it runs your version through the same behavioural checks as the reference and ends in 48 checks, 0 failure(s). The command exits 0 on success and non-zero on any failure, so it can run unattended. A full captured run is in expected-output/test-run.txt.

The checks test behaviour, not file existence: that the right exception type comes out of the right input, that __cause__ carries the original error, that an out-of-range value raises without a cause, that a bad record is rejected while the rest of the file still processes, that a missing file exits non-zero, that retry sleeps 0.05 then 0.10 and does not retry a ValueError, and that the log file really received a chained traceback.

Cleanup

The lab writes exactly one file, the log:

rm -f triage.log

To reset your work: git checkout -- starter/triage.py. The test runner creates its log files with mktemp and removes them itself.

Troubleshooting

See troubleshooting.md for the full list: unfinished exercises, why your paths differ from the captured ones, missing ~~~^^^ caret lines on older Pythons, json.JSONDecodeError being a subclass of ValueError, a chained traceback that lost its cause, an empty log file, stdout/stderr ordering, and the test runner's two starter states.

Security notes

See security.md. Short version: the lab makes no network calls and needs no privileges. Its central point is a security one — a swallowed exception hides the events you most need to see (a permission denial, a failed integrity check), a bare except: even traps Ctrl-C, an uncapped retry loop is a load attack on someone else's service, tracebacks in logs can carry sensitive values, and assert is not a validation mechanism because -O deletes it.

Extension exercises

  1. Add a --strict mode: a second run mode in which the first RecordError aborts the whole run with exit 1 instead of being logged and skipped. Decide where that decision belongs — the parser, the loop, or main() — and write one sentence justifying it.
  2. Add a retry_on parameter to the decorator so the caller passes the tuple of exception types to retry, defaulting to (ConnectionError, TimeoutError). Add a test proving a type outside the tuple propagates on the first attempt.
  3. Add jitter to the backoff — a small deterministic offset derived from the attempt number, not from random — and explain in a comment why real retry policies randomize delays (to stop many clients retrying in lockstep) and why this lab cannot.
  4. Replace the two print(..., file=sys.stderr) calls in main() with logging.error(...) plus a logging.StreamHandler, so one configuration controls both the file and the screen. Confirm the tests still pass.
  5. Write tests/test_handlers.py that imports triage and asserts the same chaining behaviour with plain assert statements, prints all tests passed, and exits 0 — then explain why those asserts are fine in a test file but would be wrong as input validation in parse_record.
  • Previous day: Day 65 — CSV and JSON in the Real World (labs/sections/programming-with-python/day-065-csv-and-json-in-the-real/).
  • Next day: Day 67 — Classes and Objects (labs/sections/programming-with-python/day-067-classes-and-objects/), where the two-line custom exception you used today becomes a class you understand completely.
  • Week 10 project: the Expense Tracker — CSV import/export, category handling, and monthly summary reports, where every parse and every file read needs exactly the strategy you built here.

Expected output

FIELDS.md

# Expected output — Day 066 lab

These are real captured runs from the authoring machine (macOS on Apple
Silicon, Python 3.14.0, bash 3.2.57, 2026-07-19). Every program here is
deterministic — no randomness, no network, no clock reading — so given the
same input it produces the same output and the same exit code on every
platform Python 3 runs on.

## Files

- `tracebacks.txt` — the three genuine tracebacks from
  `examples/raw_triage.py`: `FileNotFoundError`, `ValueError`, `KeyError`.
- `sample-run.txt` — a full session: the swallowing anti-pattern, the
  rebuilt `examples/triage.py` on clean and dirty input, a missing file,
  the resulting log, and the retry demo with its chained traceback.
- `log-sample.txt` — the log file `logging.exception` writes when a record
  is rejected, including the chained traceback.
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
  still unfinished: `29 checks, 0 failure(s).`, exit 0.

## About the absolute paths

**Your tracebacks will not match these character for character, and they are
not supposed to.** A traceback prints the absolute path of every source file
in the stack, so the paths in your output are wherever you cloned this
repository. In the captured files the lab directory has been replaced with
the placeholder `<lab>` so the output stays readable; nothing else was
edited. What must match is the **exception type**, the **message**, the
**order of the frames**, and the **exit code**.

Two more differences you may legitimately see:

- The line numbers in tracebacks point into `examples/raw_triage.py` and
  `examples/triage.py`. If you edit those files, the numbers move.
- Python 3.11 and newer print `~~~^^^` caret lines under the exact failing
  sub-expression. On Python 3.8-3.10 those caret lines are absent and the
  traceback is otherwise identical. The tests never depend on them.

## Capture note: `python3 -u`

The captured sessions were run with `python3 -u` (unbuffered). In an
interactive terminal, standard output is line-buffered, so a program's
stdout and stderr interleave in the order the lines were written. When you
redirect output to a file, stdout becomes block-buffered and the two streams
can come out reordered. `-u` makes a redirected capture match what you see in
a terminal. Running the plain commands from the README in your own terminal
gives the same ordering as these files.

## Required behaviour on every platform

The handlers in `triage.py` must satisfy exactly:

| Call | Result |
| --- | --- |
| `parse_record('{"id": "P-1", "severity": 3}', 1)` | `{"id": "P-1", "severity": 3}` |
| `parse_record('{"id": "P-1"}', 7)` | raises `RecordError` mentioning `line 7` and `severity`; `__cause__` is a `KeyError` |
| `parse_record('{"id": "P-1", "severity": "high"}', 2)` | raises `RecordError`; `__cause__` is a `ValueError` (not a `JSONDecodeError`) |
| `parse_record('this is not json', 4)` | raises `RecordError` saying `not valid JSON`; `__cause__` is a `json.JSONDecodeError` |
| `parse_record('{"id": "P-1", "severity": 9}', 3)` | raises `RecordError` saying `outside 1-5`; `__cause__` is `None` |
| `band(1), band(3), band(5)` | `"immediate"`, `"urgent"`, `"routine"` |
| `summarize([...])` | a dict counting the three bands |
| `retry` on a callable that fails twice | succeeds on attempt 3; sleeps `0.05` then `0.10` seconds |
| `retry` on a callable that always fails | raises `DispatchError` whose `__cause__` is the last `ConnectionError` |
| `retry` on a callable raising `ValueError` | the `ValueError` propagates immediately, after one call |

The shell must satisfy exactly:

| Command | Output | Exit code |
| --- | --- | --- |
| `python3 examples/triage.py examples/samples/intake.jsonl` | `admitted 5 record(s), rejected 0`, `immediate  2` | 0 |
| `python3 examples/triage.py examples/samples/bad-severity.jsonl` | `rejected: line 2: severity is not a whole number` (stderr) | 0 |
| `python3 examples/triage.py examples/samples/missing-field.jsonl` | `rejected: line 2: missing field 'severity'` (stderr) | 0 |
| `python3 examples/triage.py examples/samples/no-such-file.jsonl` | `error: no such intake file: ...` (stderr) | 1 |
| `python3 examples/triage.py` | `usage: python3 triage.py <records.jsonl> [logfile]` (stderr) | 2 |
| `python3 examples/swallowing.py examples/samples/no-such-file.jsonl` | `total severity: 0`, `done` — the failure hidden | 0 |

And the log file written by `logging.exception` must contain, for a rejected
record: the line `ERROR: rejected record on line 2`, a
`Traceback (most recent call last):` block, the chaining sentence
`The above exception was the direct cause of the following exception`, and
the original `ValueError: invalid literal for int()`.

## Platform notes

- macOS and Linux behave identically here; the only difference is the shell
  prompt shown before each command in the captures.
- `mktemp -t NAME` is used by the test runner. macOS and GNU coreutils spell
  its template rules slightly differently, so the runner passes a template
  form (`triage-test.XXXXXX`) that both accept.
- The exact wording of `FileNotFoundError`'s message
  (`[Errno 2] No such file or directory: ...`) comes from the operating
  system through Python's `OSError`, so it can differ on other platforms.
  The tests check the program's own message (`error: no such intake file`)
  and the exit code, never the OS wording.
- Windows: run everything inside WSL. Native Windows Python raises the same
  exceptions, but paths in tracebacks use backslashes and `bash` is not
  available for the test runner.

log-sample.txt

$ python3 -u examples/triage.py examples/samples/bad-severity.jsonl triage.log > /dev/null 2>&1
$ cat triage.log
ERROR: rejected record on line 2
Traceback (most recent call last):
  File "<lab>/examples/triage.py", line 56, in parse_record
    record = {"id": raw["id"], "severity": int(raw["severity"])}
                                           ~~~^^^^^^^^^^^^^^^^^
ValueError: invalid literal for int() with base 10: 'high'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<lab>/examples/triage.py", line 176, in load_records
    records.append(parse_record(line, line_number))
                   ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^
  File "<lab>/examples/triage.py", line 64, in parse_record
    raise RecordError(f"line {line_number}: severity is not a whole number") from err
RecordError: line 2: severity is not a whole number
INFO: run complete: 2 admitted, 1 rejected

sample-run.txt

$ python3 -u examples/swallowing.py examples/samples/no-such-file.jsonl ; echo "exit: $?"
total severity: 0
done
exit: 0

$ python3 -u examples/triage.py examples/samples/intake.jsonl ; echo "exit: $?"
admitted 5 record(s), rejected 0
immediate  2
urgent     2
routine    1
attempt 1 failed (ward system unavailable (call 1)); retrying in 0.05s
attempt 2 failed (ward system unavailable (call 2)); retrying in 0.10s
dispatched 5 record(s) to the ward system
exit: 0

$ python3 -u examples/triage.py examples/samples/missing-field.jsonl ; echo "exit: $?"
admitted 2 record(s), rejected 1
immediate  1
urgent     1
routine    0
rejected: line 2: missing field 'severity'
attempt 1 failed (ward system unavailable (call 1)); retrying in 0.05s
attempt 2 failed (ward system unavailable (call 2)); retrying in 0.10s
dispatched 2 record(s) to the ward system
exit: 0

$ python3 -u examples/triage.py examples/samples/bad-severity.jsonl ; echo "exit: $?"
admitted 2 record(s), rejected 1
immediate  1
urgent     1
routine    0
rejected: line 2: severity is not a whole number
attempt 1 failed (ward system unavailable (call 1)); retrying in 0.05s
attempt 2 failed (ward system unavailable (call 2)); retrying in 0.10s
dispatched 2 record(s) to the ward system
exit: 0

$ python3 -u examples/triage.py examples/samples/no-such-file.jsonl ; echo "exit: $?"
error: no such intake file: examples/samples/no-such-file.jsonl
exit: 1

$ cat triage.log
ERROR: intake file missing
Traceback (most recent call last):
  File "<lab>/examples/triage.py", line 203, in main
    records, problems = load_records(path)
                        ~~~~~~~~~~~~^^^^^^
  File "<lab>/examples/triage.py", line 169, in load_records
    handle = open(path, "r", encoding="utf-8")
FileNotFoundError: [Errno 2] No such file or directory: 'examples/samples/no-such-file.jsonl'

$ python3 -u examples/dispatch_demo.py
--- dispatch(): flaky, recovers on the third attempt ---
attempt 1 failed (ward system unavailable (call 1)); retrying in 0.05s
attempt 2 failed (ward system unavailable (call 2)); retrying in 0.10s
dispatched 5 record(s) to the ward system

--- dispatch_offline(): never recovers, gives up with chaining ---
attempt 1 failed (offline ward system never answers); retrying in 0.05s
attempt 2 failed (offline ward system never answers); retrying in 0.10s
Traceback (most recent call last):
  File "<lab>/examples/triage.py", line 112, in wrapper
    return function(*args, **kwargs)
  File "<lab>/examples/triage.py", line 152, in dispatch_offline
    raise ConnectionError("offline ward system never answers")
ConnectionError: offline ward system never answers

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<lab>/examples/dispatch_demo.py", line 30, in <module>
    triage.dispatch_offline(COUNTS)
    ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^
  File "<lab>/examples/triage.py", line 123, in wrapper
    raise DispatchError(
        f"{function.__name__} gave up after {attempts} attempts"
    ) from last_error
triage.DispatchError: dispatch_offline gave up after 3 attempts

test-run.txt

Testing the anti-pattern in examples/swallowing.py ...
  ok: bare except hides a missing file and still exits 0 (the bug we are fixing)
Testing that examples/raw_triage.py really raises the three errors ...
  ok: raw_triage on no-such-file.jsonl raises FileNotFoundError
  ok: raw_triage on bad-severity.jsonl raises ValueError
  ok: raw_triage on missing-field.jsonl raises KeyError
Testing the handlers in <lab>/examples (direct calls) ...
  ok: parse_record accepts a good line
  ok: missing field -> RecordError chained from KeyError
  ok: bad severity -> RecordError chained from ValueError
  ok: malformed JSON -> RecordError chained from JSONDecodeError
  ok: out-of-range severity -> RecordError with NO cause
  ok: band and summarize count the three bands
  ok: retry succeeds after two failures, with backoff
  ok: retry gives up as DispatchError chained from the last error
  ok: retry does not retry a non-transient error
Testing <lab>/examples/triage.py end to end ...
  ok: clean intake: 5 admitted, exit 0
  ok: clean intake: two immediate
  ok: bad severity: rejected by name, rest still processed
  ok: missing field: rejected by name, rest still processed
  ok: missing field: 2 admitted, 1 rejected
  ok: retry backs off before succeeding
  ok: missing file: fails fast, exit 1
  ok: no argument: usage message, exit 2
  ok: log: ERROR line for the rejected record
  ok: log: full traceback recorded
  ok: log: chaining preserved in the log
  ok: log: the original ValueError is still there
Testing starter/triage.py ...
  ok: starter is valid Python
Note: starter/triage.py still has unfinished exercises — testing structure only.
  ok: starter defines parse_record
  ok: starter defines load_records
  ok: starter defines retry

29 checks, 0 failure(s).
exit: 0

tracebacks.txt

$ python3 -u examples/raw_triage.py examples/samples/no-such-file.jsonl ; echo "exit: $?"
Traceback (most recent call last):
  File "<lab>/examples/raw_triage.py", line 45, in <module>
    sys.exit(main(sys.argv))
             ~~~~^^^^^^^^^^
  File "<lab>/examples/raw_triage.py", line 40, in main
    print("total severity:", total_severity(argv[1]))
                             ~~~~~~~~~~~~~~^^^^^^^^^
  File "<lab>/examples/raw_triage.py", line 27, in total_severity
    with open(path, "r", encoding="utf-8") as handle:
         ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: 'examples/samples/no-such-file.jsonl'
exit: 1

$ python3 -u examples/raw_triage.py examples/samples/bad-severity.jsonl ; echo "exit: $?"
Traceback (most recent call last):
  File "<lab>/examples/raw_triage.py", line 45, in <module>
    sys.exit(main(sys.argv))
             ~~~~^^^^^^^^^^
  File "<lab>/examples/raw_triage.py", line 40, in main
    print("total severity:", total_severity(argv[1]))
                             ~~~~~~~~~~~~~~^^^^^^^^^
  File "<lab>/examples/raw_triage.py", line 32, in total_severity
    total += read_severity(json.loads(line))
             ~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
  File "<lab>/examples/raw_triage.py", line 21, in read_severity
    return int(record["severity"])
ValueError: invalid literal for int() with base 10: 'high'
exit: 1

$ python3 -u examples/raw_triage.py examples/samples/missing-field.jsonl ; echo "exit: $?"
Traceback (most recent call last):
  File "<lab>/examples/raw_triage.py", line 45, in <module>
    sys.exit(main(sys.argv))
             ~~~~^^^^^^^^^^
  File "<lab>/examples/raw_triage.py", line 40, in main
    print("total severity:", total_severity(argv[1]))
                             ~~~~~~~~~~~~~~^^^^^^^^^
  File "<lab>/examples/raw_triage.py", line 32, in total_severity
    total += read_severity(json.loads(line))
             ~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
  File "<lab>/examples/raw_triage.py", line 21, in read_severity
    return int(record["severity"])
               ~~~~~~^^^^^^^^^^^^
KeyError: 'severity'
exit: 1

Source files

examples/dispatch_demo.py (1008 bytes)
#!/usr/bin/env python3
"""dispatch_demo.py — the @retry helper shown both ways.

Run it from the lab directory:

    python3 examples/dispatch_demo.py

First it calls dispatch(), which fails twice and then succeeds, so you see
backoff working. Then it calls dispatch_offline(), which never succeeds, so
you see the retries exhausted and DispatchError raised FROM the underlying
ConnectionError — the chained traceback with its
"The above exception was the direct cause of the following exception" line.

Both operations are deterministic: no randomness, no network, no clock
reading. The same run produces the same output every time.
"""

import traceback

import triage

COUNTS = {"immediate": 2, "urgent": 2, "routine": 1}

print("--- dispatch(): flaky, recovers on the third attempt ---")
print(triage.dispatch(COUNTS))

print()
print("--- dispatch_offline(): never recovers, gives up with chaining ---")
try:
    triage.dispatch_offline(COUNTS)
except triage.DispatchError:
    traceback.print_exc()
examples/raw_triage.py (1460 bytes)
#!/usr/bin/env python3
"""raw_triage.py — the intake reader with NO error handling at all.

This script exists to be broken. It reads a JSON-lines intake file and adds
up the severity numbers. When anything goes wrong it does nothing about it,
so Python does the only thing it can: it unwinds the call stack and prints
a traceback. Reading those three tracebacks is Exercise 1 of the lab.

    python3 examples/raw_triage.py examples/samples/intake.jsonl
    python3 examples/raw_triage.py examples/samples/no-such-file.jsonl
    python3 examples/raw_triage.py examples/samples/bad-severity.jsonl
    python3 examples/raw_triage.py examples/samples/missing-field.jsonl
"""

import json
import sys


def read_severity(record):
    """Pull the severity out of one decoded record. Raises KeyError/ValueError."""
    return int(record["severity"])


def total_severity(path):
    """Open the file and add up every record's severity. Handles nothing."""
    total = 0
    with open(path, "r", encoding="utf-8") as handle:
        for line in handle:
            line = line.strip()
            if not line:
                continue
            total += read_severity(json.loads(line))
    return total


def main(argv):
    if len(argv) < 2:
        print("usage: python3 raw_triage.py <records.jsonl>", file=sys.stderr)
        return 2
    print("total severity:", total_severity(argv[1]))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
examples/samples/bad-severity.jsonl (148 bytes)
{"id": "P-201", "name": "Ken", "severity": 2}
{"id": "P-202", "name": "Dennis", "severity": "high"}
{"id": "P-203", "name": "Brian", "severity": 4}
examples/samples/intake.jsonl (240 bytes)
{"id": "P-101", "name": "Ada", "severity": 1}
{"id": "P-102", "name": "Grace", "severity": 3}
{"id": "P-103", "name": "Alan", "severity": 5}
{"id": "P-104", "name": "Edsger", "severity": 2}
{"id": "P-105", "name": "Barbara", "severity": 4}
examples/samples/missing-field.jsonl (134 bytes)
{"id": "P-301", "name": "Katherine", "severity": 1}
{"id": "P-302", "name": "Dorothy"}
{"id": "P-303", "name": "Mary", "severity": 3}
examples/swallowing.py (1254 bytes)
#!/usr/bin/env python3
"""swallowing.py — the SAME reader, wrapped in a bare except. Do not copy this.

This is the "before" picture of the lab: a script that catches everything,
says nothing, and reports success no matter what happened. Run it on a file
that does not exist and it still prints "done" and exits 0 — the failure is
completely invisible to you and to any script that calls it.

    python3 examples/swallowing.py examples/samples/no-such-file.jsonl ; echo "exit: $?"
    python3 examples/swallowing.py examples/samples/bad-severity.jsonl ; echo "exit: $?"

Every line marked WRONG below is an anti-pattern the lesson names. Your job
in the lab is to rebuild this program's error strategy in starter/triage.py.
"""

import json
import sys

total = 0

try:
    path = sys.argv[1]
    with open(path, "r", encoding="utf-8") as handle:
        for line in handle:
            line = line.strip()
            if not line:
                continue
            total += int(json.loads(line)["severity"])
except:  # WRONG: bare except catches everything, including KeyboardInterrupt
    pass  # WRONG: and then says nothing at all about it

print("total severity:", total)
print("done")
sys.exit(0)  # WRONG: reports success whatever happened
examples/triage.py (9087 bytes)
#!/usr/bin/env python3
"""triage.py — the same intake reader, with a real error-handling strategy.

The strategy, stated in one place so you can check the code against it:

  * A record that cannot be read is an EXPECTED condition. It is rejected by
    name, logged with its full traceback, and the rest of the file is still
    processed. That is a decision the record loop can actually make.
  * A missing or unreadable intake FILE is a boundary failure. The loader
    does not catch it; it propagates to main(), which reports it and exits
    non-zero. Fail fast where the program can no longer do its job.
  * A transient dispatch failure is retried with backoff. When the retries
    are exhausted the helper raises DispatchError FROM the last underlying
    error, so the traceback keeps the real cause.
  * Nothing is ever caught and silently discarded.

    python3 examples/triage.py examples/samples/intake.jsonl
    python3 examples/triage.py examples/samples/bad-severity.jsonl
    python3 examples/triage.py examples/samples/no-such-file.jsonl ; echo "exit: $?"
"""

import json
import logging
import sys
import time

# Two custom exceptions. A custom exception is just a class that inherits
# from Exception; the two-line form below is the whole of it. Classes get
# their own lesson tomorrow (Day 67) — for today this minimal form is all
# you need, and all you should write.


class RecordError(Exception):
    """One intake record could not be admitted."""


class DispatchError(Exception):
    """Hand-off to the ward system failed after every retry."""


# ---------------------------------------------------------------- pure core


def parse_record(line, line_number):
    """Turn one line of JSON into a validated record dict.

    Returns {"id": str, "severity": int}. Raises RecordError — chained from
    the underlying error with `from` — when the line cannot be used.

    The except clauses are tried in order, so json.JSONDecodeError must come
    before ValueError: JSONDecodeError is a SUBCLASS of ValueError, and the
    broader clause would otherwise shadow it.
    """
    try:
        raw = json.loads(line)
        record = {"id": raw["id"], "severity": int(raw["severity"])}
    except json.JSONDecodeError as err:
        raise RecordError(f"line {line_number}: not valid JSON") from err
    except TypeError as err:
        raise RecordError(f"line {line_number}: expected a JSON object") from err
    except KeyError as err:
        raise RecordError(f"line {line_number}: missing field {err.args[0]!r}") from err
    except ValueError as err:
        raise RecordError(f"line {line_number}: severity is not a whole number") from err
    else:
        # The `else` block holds the code that must run ONLY when the try
        # block raised nothing. Keeping it out of `try` means a KeyError
        # raised by this range check can never be mistaken for a parse error.
        if not 1 <= record["severity"] <= 5:
            raise RecordError(
                f"line {line_number}: severity {record['severity']} is outside 1-5"
            )
        return record


def band(severity):
    """Map a 1-5 severity to a triage band. Pure; 1 is the most urgent."""
    if severity <= 2:
        return "immediate"
    if severity <= 4:
        return "urgent"
    return "routine"


def summarize(records):
    """Count records per triage band. Pure: a list in, a dict out."""
    counts = {"immediate": 0, "urgent": 0, "routine": 0}
    for record in records:
        counts[band(record["severity"])] += 1
    return counts


# ------------------------------------------------------- the retry helper


def retry(attempts=3, base_delay=0.05, sleep=time.sleep):
    """Return a decorator that retries a callable with exponential backoff.

    Only ConnectionError and TimeoutError are retried: they are the errors
    that a later attempt might genuinely survive. A ValueError would fail
    identically forever, so retrying it would only waste time.

    After `attempts` failures the last error is re-raised as DispatchError
    using `raise ... from err`, so the traceback still names the real cause.
    """

    def decorate(function):
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, attempts + 1):
                try:
                    return function(*args, **kwargs)
                except (ConnectionError, TimeoutError) as err:
                    last_error = err
                    if attempt == attempts:
                        break
                    delay = base_delay * (2 ** (attempt - 1))
                    print(
                        f"attempt {attempt} failed ({err}); retrying in {delay:.2f}s",
                        file=sys.stderr,
                    )
                    sleep(delay)
            raise DispatchError(
                f"{function.__name__} gave up after {attempts} attempts"
            ) from last_error

        wrapper.__name__ = function.__name__
        wrapper.__doc__ = function.__doc__
        return wrapper

    return decorate


# A deliberately flaky stand-in for a network call. It is DETERMINISTIC on
# purpose: a list records how many times it has been called in this process,
# so it fails exactly twice and then succeeds, every single run.
_DISPATCH_CALLS = []


@retry(attempts=3, base_delay=0.05)
def dispatch(counts):
    """Hand the summary to the ward system. Fails twice, then succeeds."""
    _DISPATCH_CALLS.append(1)
    if len(_DISPATCH_CALLS) < 3:
        raise ConnectionError(f"ward system unavailable (call {len(_DISPATCH_CALLS)})")
    return f"dispatched {sum(counts.values())} record(s) to the ward system"


@retry(attempts=3, base_delay=0.05)
def dispatch_offline(counts):
    """A ward system that is simply down. Every attempt fails."""
    raise ConnectionError("offline ward system never answers")


# ------------------------------------------------------ imperative shell


def load_records(path):
    """Read every line of `path`; return (records, problems).

    Opens a file, so it is part of the shell, not the core. A bad RECORD is
    handled here because here we can do something about it: reject it, log
    it, and keep going. A bad PATH is NOT handled here — FileNotFoundError
    and PermissionError propagate to the caller, which is the only level
    that can decide the program is over.
    """
    records = []
    problems = []
    handle = open(path, "r", encoding="utf-8")
    try:
        for line_number, line in enumerate(handle, start=1):
            line = line.strip()
            if not line:
                continue
            try:
                records.append(parse_record(line, line_number))
            except RecordError as err:
                # logging.exception records the message AND the full chained
                # traceback, at ERROR level. Only ever call it from inside an
                # except block: it reads the exception currently being handled.
                logging.exception("rejected record on line %d", line_number)
                problems.append(str(err))
    finally:
        # finally runs whether the loop finished, returned, or raised, so the
        # file handle is closed on every path out of this function.
        handle.close()
    return records, problems


def main(argv):
    if len(argv) < 2:
        print("usage: python3 triage.py <records.jsonl> [logfile]", file=sys.stderr)
        return 2
    path = argv[1]
    log_path = argv[2] if len(argv) > 2 else "triage.log"
    logging.basicConfig(
        filename=log_path,
        filemode="w",
        level=logging.INFO,
        format="%(levelname)s: %(message)s",
    )
    try:
        records, problems = load_records(path)
    except FileNotFoundError as err:
        # Handled here, at the boundary, because here we can do something:
        # tell the operator which file, and stop with a non-zero exit code.
        print(f"error: no such intake file: {err.filename}", file=sys.stderr)
        logging.exception("intake file missing")
        return 1
    except PermissionError as err:
        print(f"error: cannot read intake file: {err.filename}", file=sys.stderr)
        logging.exception("intake file unreadable")
        return 1
    else:
        counts = summarize(records)
        print(f"admitted {len(records)} record(s), rejected {len(problems)}")
        for name in ("immediate", "urgent", "routine"):
            print(f"{name:<10} {counts[name]}")
        for problem in problems:
            print(f"rejected: {problem}", file=sys.stderr)
        try:
            print(dispatch(counts))
        except DispatchError as err:
            print(f"error: {err}", file=sys.stderr)
            logging.exception("dispatch failed")
            return 1
        logging.info("run complete: %d admitted, %d rejected", len(records), len(problems))
        return 0
    finally:
        # Runs even on the `return 1` above and even on an unhandled error.
        logging.shutdown()


if __name__ == "__main__":
    sys.exit(main(sys.argv))
metadata.yml (1103 bytes)
lesson_id: D066
day: 66
kind: coding
languages: [python, bash]
setup_commands:
  - cd labs/sections/programming-with-python/day-066-exceptions-and-error-handling-strategy
  - python3 --version
run_commands:
  - python3 examples/raw_triage.py examples/samples/no-such-file.jsonl
  - python3 examples/raw_triage.py examples/samples/bad-severity.jsonl
  - python3 examples/raw_triage.py examples/samples/missing-field.jsonl
  - python3 examples/swallowing.py examples/samples/no-such-file.jsonl
  - python3 examples/triage.py examples/samples/intake.jsonl
  - python3 examples/triage.py examples/samples/missing-field.jsonl
  - python3 examples/triage.py examples/samples/no-such-file.jsonl
  - python3 examples/dispatch_demo.py
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - rm -f triage.log
  - 'git checkout -- starter/triage.py  # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-19'
executed_on: 'macOS (Apple Silicon), Python 3.14.0, bash 3.2.57 — bash tests/run_tests.sh -> 29 checks, 0 failure(s). exit 0'
requirements/README.md (1422 bytes)
# Dependencies — Day 066 lab

**Python 3 only. No third-party packages, no network, no API key.**

- `python3` (3.8 or newer; tested on 3.14.0). You set this up on Day 43.
- `bash` for the test runner (preinstalled on macOS and Linux).
- Standard library only: `json`, `logging`, `sys`, `time`, and `traceback`.
  Every one of those ships with Python. There is deliberately no
  `requirements.txt` — error handling is a language feature and a design
  discipline, not something you install.

Check your Python is present and new enough:

```bash
python3 --version
```

If that prints `Python 3.8` or higher you are ready.

## A note on Python versions

Python 3.11 and newer print *fine-grained* tracebacks: under the failing
line you get `~~~^^^` carets pointing at the exact sub-expression that
raised. On 3.8-3.10 those caret lines are simply absent and everything else
is the same. The lab's captured output was taken on 3.14.0, so it shows the
carets; nothing in the tests depends on them.

Exception chaining (`raise ... from err`, `__cause__`, and the
"The above exception was the direct cause of the following exception" line)
has been in Python since 3.0, so it works on every version this course
supports.

## Windows

Run the commands inside WSL and follow the Linux path. Native Windows Python
raises exactly the same exceptions, but the test runner needs `bash`, and
tracebacks print Windows-style paths.
starter/traceback-notes.md (2637 bytes)
# Exercise 0 — read three real tracebacks

Before you write a single handler, produce the failures and read what Python
tells you about them. Run each command from the lab directory and paste the
traceback it prints into the matching box below, then answer the three
questions under it.

The paths in your tracebacks will be **your** absolute paths, not the ones in
`expected-output/tracebacks.txt` — that file has them shortened to `<lab>` so
it stays readable. The exception type, the message, and the order of the
frames are what must match.

```bash
python3 examples/raw_triage.py examples/samples/no-such-file.jsonl
python3 examples/raw_triage.py examples/samples/bad-severity.jsonl
python3 examples/raw_triage.py examples/samples/missing-field.jsonl
```

---

## 1. `FileNotFoundError`

Command: `python3 examples/raw_triage.py examples/samples/no-such-file.jsonl`

```text
paste the traceback here
```

- Which line of `raw_triage.py` actually raised it?
- How many stack frames are shown, and what does that tell you about how
  the program got there?
- Is this an expected condition or a bug in the program?

## 2. `ValueError`

Command: `python3 examples/raw_triage.py examples/samples/bad-severity.jsonl`

```text
paste the traceback here
```

- What exact value could `int()` not convert, and which record was it in?
- Which function is innermost, and why is it printed last?
- Would retrying this call help? Why not?

## 3. `KeyError`

Command: `python3 examples/raw_triage.py examples/samples/missing-field.jsonl`

```text
paste the traceback here
```

- What is the single quoted word after `KeyError:`, and what does it name?
- The file has three records and the first one is fine. What happened to
  the third record, and why?
- Which level of the program is the right place to handle this: the
  expression that raised it, the loop over records, or `main()`? Say why.

---

## 4. What the bare `except` costs

Now run the swallowing version on the same two bad files and record what it
prints and what it exits with:

```bash
python3 examples/swallowing.py examples/samples/no-such-file.jsonl ; echo "exit: $?"
python3 examples/swallowing.py examples/samples/bad-severity.jsonl ; echo "exit: $?"
```

```text
paste both runs here
```

- What number does it print for the missing file, and why is that number
  worse than a crash?
- A script that runs this program every night checks the exit code to decide
  whether to alert someone. What would that script have concluded?
- Write one sentence naming the difference between an *expected condition*
  and a *bug*, using the three tracebacks above as your examples.
starter/triage.py (9713 bytes)
#!/usr/bin/env python3
"""starter/triage.py — YOUR working file: rebuild the error strategy.

The program in examples/swallowing.py "works": it never crashes, it always
prints "done", and it always exits 0. It is also useless, because it hides
every failure — a missing file, a bad number, a missing field, all reported
as success. Your job is to give the same program a real strategy.

The design decisions are already made for you and written into the
docstrings below. Fill in the three numbered exercises so the program keeps
those promises. `main()` is provided complete — read it, do not edit it: it
shows where a boundary failure is handled and why `finally` is there.

Work in this order:

  Exercise 0  reproduce the three tracebacks (see starter/traceback-notes.md)
  Exercise 1  parse_record  — narrow handlers, `else`, and `raise ... from`
  Exercise 2  load_records  — try/finally, and logging.exception
  Exercise 3  retry         — a decorator with backoff that re-raises chained

Then run:  bash tests/run_tests.sh
"""

import json
import logging
import sys
import time

# Two custom exceptions, provided. A custom exception is just a class that
# inherits from Exception; this two-line form is the whole of it. Classes get
# their own lesson tomorrow (Day 67) — today you only USE these two.


class RecordError(Exception):
    """One intake record could not be admitted."""


class DispatchError(Exception):
    """Hand-off to the ward system failed after every retry."""


# ---------------------------------------------------------------- pure core


def parse_record(line, line_number):
    """Turn one line of JSON into a validated record dict.

    Returns {"id": <the id>, "severity": <int 1-5>}.
    Raises RecordError, chained from the underlying error, when the line
    cannot be used.
    """
    # Exercise 1: NARROW HANDLERS, `else`, AND CHAINING.
    #
    # 1. In a `try` block, decode and build the record:
    #        raw = json.loads(line)
    #        record = {"id": raw["id"], "severity": int(raw["severity"])}
    #
    # 2. Add FOUR except clauses, each catching one specific thing and
    #    re-raising RecordError with `from err` so the cause is preserved:
    #        except json.JSONDecodeError as err:
    #            raise RecordError(f"line {line_number}: not valid JSON") from err
    #        except TypeError as err:      -> "expected a JSON object"
    #        except KeyError as err:       -> f"missing field {err.args[0]!r}"
    #        except ValueError as err:     -> "severity is not a whole number"
    #
    #    ORDER MATTERS. Clauses are tried top to bottom and the first match
    #    wins, and json.JSONDecodeError is a SUBCLASS of ValueError — so if
    #    you put ValueError first it will shadow the JSON clause and every
    #    malformed line will be reported as a bad severity.
    #
    # 3. Add an `else:` block. Code that must run only when the try block
    #    raised NOTHING belongs there, not in the try:
    #        if not 1 <= record["severity"] <= 5:
    #            raise RecordError(f"line {line_number}: severity "
    #                              f"{record['severity']} is outside 1-5")
    #        return record
    #    (Note this one is raised WITHOUT `from`: there is no underlying
    #     error to chain — the value is simply out of range.)
    raise NotImplementedError("Exercise 1: implement parse_record")


def band(severity):
    """Map a 1-5 severity to a triage band. Pure; 1 is the most urgent."""
    if severity <= 2:
        return "immediate"
    if severity <= 4:
        return "urgent"
    return "routine"


def summarize(records):
    """Count records per triage band. Pure: a list in, a dict out."""
    counts = {"immediate": 0, "urgent": 0, "routine": 0}
    for record in records:
        counts[band(record["severity"])] += 1
    return counts


# ------------------------------------------------------- the retry helper


def retry(attempts=3, base_delay=0.05, sleep=time.sleep):
    """Return a decorator that retries a callable with exponential backoff.

    After `attempts` failures, raise DispatchError FROM the last error.
    """
    # Exercise 3: A RETRY DECORATOR (a plain decorator factory — Day 58).
    #
    # def decorate(function):
    #     def wrapper(*args, **kwargs):
    #         last_error = None
    #         for attempt in range(1, attempts + 1):
    #             try:
    #                 return function(*args, **kwargs)
    #             except (ConnectionError, TimeoutError) as err:
    #                 # Retry ONLY errors a later attempt might survive.
    #                 # A ValueError would fail identically forever.
    #                 last_error = err
    #                 if attempt == attempts:
    #                     break
    #                 delay = base_delay * (2 ** (attempt - 1))
    #                 print(f"attempt {attempt} failed ({err}); "
    #                       f"retrying in {delay:.2f}s", file=sys.stderr)
    #                 sleep(delay)
    #         raise DispatchError(
    #             f"{function.__name__} gave up after {attempts} attempts"
    #         ) from last_error
    #     wrapper.__name__ = function.__name__
    #     wrapper.__doc__ = function.__doc__
    #     return wrapper
    # return decorate
    #
    # Type it out rather than pasting it: the shape (factory -> decorate ->
    # wrapper) is the part worth remembering.
    #
    # Replace the placeholder below with your version.
    def decorate(function):
        def wrapper(*args, **kwargs):
            raise NotImplementedError("Exercise 3: implement the retry decorator")

        return wrapper

    return decorate


# Deterministic stand-ins for a network call: a list counts the calls made in
# this process, so dispatch() fails exactly twice and then succeeds, and
# dispatch_offline() never succeeds. No randomness, so output never varies.
_DISPATCH_CALLS = []


@retry(attempts=3, base_delay=0.05)
def dispatch(counts):
    """Hand the summary to the ward system. Fails twice, then succeeds."""
    _DISPATCH_CALLS.append(1)
    if len(_DISPATCH_CALLS) < 3:
        raise ConnectionError(f"ward system unavailable (call {len(_DISPATCH_CALLS)})")
    return f"dispatched {sum(counts.values())} record(s) to the ward system"


@retry(attempts=3, base_delay=0.05)
def dispatch_offline(counts):
    """A ward system that is simply down. Every attempt fails."""
    raise ConnectionError("offline ward system never answers")


# ------------------------------------------------------ imperative shell


def load_records(path):
    """Read every line of `path`; return (records, problems).

    A bad RECORD is handled here, because here we can do something about it:
    reject it, log it, and carry on with the rest of the file. A bad PATH is
    NOT handled here — it must propagate to main(), the only level that can
    decide the whole run is over.
    """
    records = []
    problems = []
    handle = open(path, "r", encoding="utf-8")
    # Exercise 2: try/finally AND logging.exception.
    #
    # 1. Wrap the loop below in `try:` ... `finally: handle.close()` so the
    #    file is closed on every path out of this function — normal end,
    #    early return, or an exception on the way through.
    #
    # 2. Inside the loop, wrap the parse_record call in its own try/except
    #    that catches ONLY RecordError (never a bare except, never
    #    `except Exception`), and in the handler:
    #        logging.exception("rejected record on line %d", line_number)
    #        problems.append(str(err))
    #    logging.exception writes the message AND the full chained traceback
    #    at ERROR level. Call it only from inside an except block — it reads
    #    the exception currently being handled.
    #
    # Replace the loop below with your version.
    for line_number, line in enumerate(handle, start=1):
        line = line.strip()
        if not line:
            continue
        records.append(parse_record(line, line_number))
    handle.close()
    return records, problems


def main(argv):
    """Provided complete — read it, do not edit it."""
    if len(argv) < 2:
        print("usage: python3 triage.py <records.jsonl> [logfile]", file=sys.stderr)
        return 2
    path = argv[1]
    log_path = argv[2] if len(argv) > 2 else "triage.log"
    logging.basicConfig(
        filename=log_path,
        filemode="w",
        level=logging.INFO,
        format="%(levelname)s: %(message)s",
    )
    try:
        records, problems = load_records(path)
    except FileNotFoundError as err:
        print(f"error: no such intake file: {err.filename}", file=sys.stderr)
        logging.exception("intake file missing")
        return 1
    except PermissionError as err:
        print(f"error: cannot read intake file: {err.filename}", file=sys.stderr)
        logging.exception("intake file unreadable")
        return 1
    else:
        counts = summarize(records)
        print(f"admitted {len(records)} record(s), rejected {len(problems)}")
        for name in ("immediate", "urgent", "routine"):
            print(f"{name:<10} {counts[name]}")
        for problem in problems:
            print(f"rejected: {problem}", file=sys.stderr)
        try:
            print(dispatch(counts))
        except DispatchError as err:
            print(f"error: {err}", file=sys.stderr)
            logging.exception("dispatch failed")
            return 1
        logging.info("run complete: %d admitted, %d rejected", len(records), len(problems))
        return 0
    finally:
        logging.shutdown()


if __name__ == "__main__":
    sys.exit(main(sys.argv))
tests/run_tests.sh (10464 bytes)
#!/usr/bin/env bash
# Tests for the Day 066 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# These checks test BEHAVIOUR, not the presence of files: that the right
# exception type comes out of the right input, that __cause__ carries the
# original error (chaining), that a bad record is rejected while the rest of
# the file still processes, that a missing file exits non-zero, that the
# retry helper backs off and then gives up with the cause preserved, and
# that logging.exception really wrote a chained traceback to the log file.
#
# Everything is deterministic: no randomness, no network, no clock reading.
# Exits 0 only if every check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
samples="${lab_dir}/examples/samples"
failures=0
checks=0

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

# check_py <label> <module_dir> <python-body>
# Runs assertions against `triage` imported from module_dir. Any AssertionError
# (or any other exception) fails the check.
check_py() {
  local label="$1" module_dir="$2" body="$3"
  if PYTHONPATH="${module_dir}" python3 -c "import triage
${body}" >/dev/null 2>&1; then
    check "${label}" "yes"
  else
    check "${label}" "no"
  fi
}

# check_run <label> <script> <args...> :: expects exit code in EXPECT_EXIT and
# a substring in EXPECT_TEXT (searched in combined stdout+stderr).
check_run() {
  local label="$1" script="$2" expect_exit="$3" needle="$4"
  shift 4
  local out code logfile
  logfile="$(mktemp -t triage-test.XXXXXX)"
  out="$(python3 "${script}" "$@" "${logfile}" 2>&1)"
  code=$?
  rm -f "${logfile}"
  if [ "${code}" -eq "${expect_exit}" ] && printf '%s' "${out}" | grep -qF "${needle}"; then
    check "${label}" "yes"
  else
    check "${label}" "no"
    echo "    (exit ${code}, expected ${expect_exit}; output: ${out})"
  fi
}

run_unit_checks() {
  local module_dir="$1"
  echo "Testing the handlers in ${module_dir} (direct calls) ..."

  check_py "parse_record accepts a good line" "${module_dir}" \
    'import json
record = triage.parse_record(json.dumps({"id": "P-1", "name": "Ada", "severity": 3}), 1)
assert record == {"id": "P-1", "severity": 3}, record'

  check_py "missing field -> RecordError chained from KeyError" "${module_dir}" \
    'import json
line = json.dumps({"id": "P-1"})
try:
    triage.parse_record(line, 7)
except triage.RecordError as err:
    assert "line 7" in str(err), str(err)
    assert "severity" in str(err), str(err)
    assert isinstance(err.__cause__, KeyError), err.__cause__
else:
    raise SystemExit("no RecordError raised")'

  check_py "bad severity -> RecordError chained from ValueError" "${module_dir}" \
    'import json
line = json.dumps({"id": "P-1", "severity": "high"})
try:
    triage.parse_record(line, 2)
except triage.RecordError as err:
    assert isinstance(err.__cause__, ValueError), err.__cause__
    assert not isinstance(err.__cause__, json.JSONDecodeError), err.__cause__
else:
    raise SystemExit("no RecordError raised")'

  check_py "malformed JSON -> RecordError chained from JSONDecodeError" "${module_dir}" \
    'import json
try:
    triage.parse_record("this is not json", 4)
except triage.RecordError as err:
    assert "not valid JSON" in str(err), str(err)
    assert isinstance(err.__cause__, json.JSONDecodeError), err.__cause__
else:
    raise SystemExit("no RecordError raised")'

  check_py "out-of-range severity -> RecordError with NO cause" "${module_dir}" \
    'import json
line = json.dumps({"id": "P-1", "severity": 9})
try:
    triage.parse_record(line, 3)
except triage.RecordError as err:
    assert "outside 1-5" in str(err), str(err)
    assert err.__cause__ is None, err.__cause__
else:
    raise SystemExit("no RecordError raised")'

  check_py "band and summarize count the three bands" "${module_dir}" \
    'assert triage.band(1) == "immediate" and triage.band(3) == "urgent" and triage.band(5) == "routine"
counts = triage.summarize([{"severity": 1}, {"severity": 2}, {"severity": 4}, {"severity": 5}])
assert counts == {"immediate": 2, "urgent": 1, "routine": 1}, counts'

  check_py "retry succeeds after two failures, with backoff" "${module_dir}" \
    'calls = []
delays = []
@triage.retry(attempts=3, base_delay=0.05, sleep=delays.append)
def flaky():
    calls.append(1)
    if len(calls) < 3:
        raise ConnectionError("not yet")
    return "ok"
assert flaky() == "ok"
assert len(calls) == 3, calls
assert delays == [0.05, 0.1], delays'

  check_py "retry gives up as DispatchError chained from the last error" "${module_dir}" \
    '@triage.retry(attempts=3, base_delay=0.05, sleep=lambda seconds: None)
def always_down():
    raise ConnectionError("down")
try:
    always_down()
except triage.DispatchError as err:
    assert "3 attempts" in str(err), str(err)
    assert isinstance(err.__cause__, ConnectionError), err.__cause__
else:
    raise SystemExit("no DispatchError raised")'

  check_py "retry does not retry a non-transient error" "${module_dir}" \
    'calls = []
@triage.retry(attempts=3, base_delay=0.05, sleep=lambda seconds: None)
def broken():
    calls.append(1)
    raise ValueError("permanently wrong")
try:
    broken()
except ValueError:
    assert len(calls) == 1, calls
else:
    raise SystemExit("ValueError should have propagated on the first attempt")'
}

run_end_to_end_checks() {
  local script="$1"
  echo "Testing ${script} end to end ..."

  check_run "clean intake: 5 admitted, exit 0" "${script}" 0 "admitted 5 record(s), rejected 0" \
    "${samples}/intake.jsonl"
  check_run "clean intake: two immediate" "${script}" 0 "immediate  2" \
    "${samples}/intake.jsonl"
  check_run "bad severity: rejected by name, rest still processed" "${script}" 0 \
    "rejected: line 2: severity is not a whole number" "${samples}/bad-severity.jsonl"
  check_run "missing field: rejected by name, rest still processed" "${script}" 0 \
    "rejected: line 2: missing field 'severity'" "${samples}/missing-field.jsonl"
  check_run "missing field: 2 admitted, 1 rejected" "${script}" 0 \
    "admitted 2 record(s), rejected 1" "${samples}/missing-field.jsonl"
  check_run "retry backs off before succeeding" "${script}" 0 \
    "retrying in 0.10s" "${samples}/intake.jsonl"
  check_run "missing file: fails fast, exit 1" "${script}" 1 \
    "error: no such intake file" "${samples}/no-such-file.jsonl"

  # No argument at all: the shell prints usage and exits 2.
  local usage_out usage_code
  usage_out="$(python3 "${script}" 2>&1)"
  usage_code=$?
  if [ "${usage_code}" -eq 2 ] && printf '%s' "${usage_out}" | grep -qF "usage: python3 triage.py"; then
    check "no argument: usage message, exit 2" "yes"
  else
    check "no argument: usage message, exit 2" "no"
    echo "    (exit ${usage_code}; output: ${usage_out})"
  fi

  # The log file must contain a real chained traceback written by
  # logging.exception -- not just the message.
  local logfile out
  logfile="$(mktemp -t triage-log.XXXXXX)"
  python3 "${script}" "${samples}/bad-severity.jsonl" "${logfile}" >/dev/null 2>&1
  out="$(cat "${logfile}")"
  printf '%s' "${out}" | grep -qF "ERROR: rejected record on line 2" \
    && check "log: ERROR line for the rejected record" "yes" \
    || check "log: ERROR line for the rejected record" "no"
  printf '%s' "${out}" | grep -qF "Traceback (most recent call last):" \
    && check "log: full traceback recorded" "yes" \
    || check "log: full traceback recorded" "no"
  printf '%s' "${out}" | grep -qF "The above exception was the direct cause of the following exception" \
    && check "log: chaining preserved in the log" "yes" \
    || check "log: chaining preserved in the log" "no"
  printf '%s' "${out}" | grep -qF "ValueError: invalid literal for int()" \
    && check "log: the original ValueError is still there" "yes" \
    || check "log: the original ValueError is still there" "no"
  rm -f "${logfile}"
}

echo "Testing the anti-pattern in examples/swallowing.py ..."
swallow_out="$(python3 "${lab_dir}/examples/swallowing.py" "${samples}/no-such-file.jsonl" 2>&1)"
swallow_code=$?
if [ "${swallow_code}" -eq 0 ] && printf '%s' "${swallow_out}" | grep -qF "done"; then
  check "bare except hides a missing file and still exits 0 (the bug we are fixing)" "yes"
else
  check "bare except hides a missing file and still exits 0 (the bug we are fixing)" "no"
fi

echo "Testing that examples/raw_triage.py really raises the three errors ..."
for pair in "no-such-file.jsonl:FileNotFoundError" "bad-severity.jsonl:ValueError" "missing-field.jsonl:KeyError"; do
  sample="${pair%%:*}"
  wanted="${pair##*:}"
  raw_out="$(python3 "${lab_dir}/examples/raw_triage.py" "${samples}/${sample}" 2>&1)"
  raw_code=$?
  if [ "${raw_code}" -ne 0 ] && printf '%s' "${raw_out}" | grep -qF "${wanted}:"; then
    check "raw_triage on ${sample} raises ${wanted}" "yes"
  else
    check "raw_triage on ${sample} raises ${wanted}" "no"
  fi
done

# --- Reference: always tested strictly ---
run_unit_checks "${lab_dir}/examples"
run_end_to_end_checks "${lab_dir}/examples/triage.py"

# --- Learner starter ---
echo "Testing starter/triage.py ..."
starter="${lab_dir}/starter/triage.py"
if python3 -c "compile(open('${starter}').read(), '${starter}', 'exec')" 2>/dev/null; then
  check "starter is valid Python" "yes"
else
  check "starter is valid Python" "no"
fi

if grep -q 'NotImplementedError' "${starter}"; then
  echo "Note: starter/triage.py still has unfinished exercises — testing structure only."
  grep -q 'def parse_record' "${starter}" && check "starter defines parse_record" "yes" || check "starter defines parse_record" "no"
  grep -q 'def load_records' "${starter}" && check "starter defines load_records" "yes" || check "starter defines load_records" "no"
  grep -q 'def retry' "${starter}" && check "starter defines retry" "yes" || check "starter defines retry" "no"
elif grep -qE '^[[:space:]]*except[[:space:]]*:' "${starter}"; then
  check "starter has no bare except left" "no"
  echo "    (a bare 'except:' is still in starter/triage.py — replace it with narrow handlers)"
else
  check "starter has no bare except left" "yes"
  run_unit_checks "${lab_dir}/starter"
  run_end_to_end_checks "${lab_dir}/starter/triage.py"
fi

echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]

Troubleshooting

Troubleshooting — Day 066 lab

python: command not found

Use python3 explicitly, as every command in this lab does. Check with python3 --version.

NotImplementedError: Exercise 1: implement parse_record

Expected until you finish the exercises in starter/triage.py. Each unfinished piece raises NotImplementedError on purpose so you cannot mistake an empty function for a working one. Replace the placeholder with the body described in the comment above it.

Notice that NotImplementedError reaches you as a loud traceback. If you had wrapped the call in a bare except:, your own unfinished code would have been silently swallowed too — which is exactly the failure mode this lab is about.

My traceback shows different file paths from expected-output/

That is correct and unavoidable. A traceback prints the absolute path of every source file in the stack, so yours show wherever you cloned this repository. The captured files replace the lab directory with <lab> to stay readable. Compare the exception type, the message, the order of the frames, and the exit code — never the paths.

My traceback has no ~~~^^^ caret lines

You are on Python 3.10 or older. Fine-grained "which sub-expression raised" carets arrived in Python 3.11. Everything else about the traceback is the same and every test still passes.

ValueError where I expected a JSON error

json.JSONDecodeError is a subclass of ValueError. Except clauses are tried top to bottom and the first match wins, so if except ValueError: appears above except json.JSONDecodeError:, the broad clause swallows the narrow one and every malformed line is reported as a bad severity. Put the most specific clause first.

RecordError is raised but the traceback does not show the original cause

You wrote raise RecordError(...) inside the handler without from err. Python still records the original as the implicit context and prints "During handling of the above exception, another exception occurred" — but err.__cause__ is None and the relationship reads as an accident. Use raise RecordError(...) from err to state that the original error is the direct cause; then __cause__ is set and the traceback says "The above exception was the direct cause of the following exception". The test missing field -> RecordError chained from KeyError checks exactly this.

Nothing appears in triage.log

Three usual causes:

  1. logging.basicConfig(...) was never called, or was called after the first log record. basicConfig configures the root logger only once, and only if it has no handlers yet.
  2. You called logging.error(...) instead of logging.exception(...). logging.exception is the one that attaches the traceback, and it must be called from inside an except block.
  3. You passed a log path in a directory that does not exist. The default is triage.log in the directory you ran the command from.

logging.exception printed to my screen instead of the file

You called it outside main() — for example by importing triage and calling load_records directly. Without basicConfig, the logging module falls back to its "last resort" handler, which writes to standard error. Run through python3 examples/triage.py ... to get file logging.

The retry lines and the summary come out in a strange order

Standard output is block-buffered when it is redirected to a file or a pipe, while standard error is not, so the two streams can be reordered in a captured file. In a terminal they interleave correctly. Add -u (python3 -u examples/triage.py ...) to make redirected output match, which is how expected-output/sample-run.txt was captured.

The retry demo takes a moment

dispatch() sleeps 0.05 s then 0.10 s before its third, successful attempt — about 0.15 s in total, on purpose, so you can see backoff happening. The delays are computed, not random, so they are identical on every run. The tests replace sleep with a function that records the delay instead of waiting, which is why the whole suite is fast.

bash: tests/run_tests.sh: Permission denied

Run it through bash explicitly: bash tests/run_tests.sh. You do not need to chmod +x anything.

The suite says "testing structure only"

starter/triage.py still contains NotImplementedError, so the runner checks only that the three functions exist. Finish the exercises and the suite holds your version to the same 19 behavioural checks as the reference (29 checks becomes 48).

starter has no bare except left: FAIL

You finished the NotImplementedError stubs but a bare except: is still somewhere in starter/triage.py. Replace it with the specific exception types you actually intend to handle.

Security notes

Security notes — Day 066 lab

  • What the lab does: reads small JSON-lines files you already have on disk, prints a summary, and writes one log file (triage.log by default, or the path you name as the second argument). It makes no network connections, needs no privileges, and installs nothing. The simulated "ward system" is a local function that raises ConnectionError on a counter — there is no socket anywhere in this lab.

  • Swallowed exceptions are a security bug, not just a tidiness problem. examples/swallowing.py catches everything and exits 0. Run it against a missing file and it reports success. In a real system that pattern hides the exact events you most need to see: a permission denial, a failed integrity check, a rejected authentication, a truncated download. An attacker who can make an operation fail quietly has been handed a way to operate unobserved. Fail loudly, or at minimum log loudly.

  • Catch narrowly so you cannot catch what you did not mean to. A bare except: catches BaseException, which includes KeyboardInterrupt and SystemExit — so it can trap Ctrl-C and defeat a deliberate shutdown. It also catches MemoryError and every bug in your own code. Name the exceptions you actually expect.

  • Do not put attacker-controlled detail into your user-facing message. This lab's messages name the line number and the field, which is safe and useful. Be careful in real systems: an error message that echoes a raw path, a query, a stack trace, or a configuration value to an untrusted user leaks information about your system. The rule that works is: a short, safe message to the user; the full traceback to the log.

  • And be careful what the log itself holds. logging.exception writes the complete traceback, and tracebacks can carry the values that were in flight. If a record contains personal data or an API key, that value can land in the log file. Log identifiers (line numbers, record ids), not payloads, when the payload may be sensitive — and treat log files as data that needs the same protection as the records they describe.

  • assert is not a security check. Python's assert statements are removed entirely when the interpreter runs with -O. Anything you assert as a validation — "this token is authorised", "this index is in range" — silently disappears in that mode. Use if ...: raise ... for checks that must always run; keep assert for internal sanity checks that document what you believe to be impossible.

  • Retries can become an attack on someone else. A retry loop with no cap and no backoff turns a struggling service into a service under load from you. This lab's helper caps attempts at 3 and doubles the delay each time for exactly that reason. Never retry a request that has already had an effect (a payment, a message send) unless it is safe to repeat.

  • Reading before running: every file here is short and commented. Read examples/triage.py, examples/swallowing.py, and tests/run_tests.sh before running them. Running unread scripts is one of the most common ways developers get compromised.