Programming with Python › Files, Errors, and Object-Oriented Python › Day 66
Hands-on lab — Day 66: Exceptions and Error Handling Strategy
- ← Back to the Day 66 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-066-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
- Lesson title: Exceptions and Error Handling Strategy
- Day number: 66 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-066-exceptions-and-error-handling-strategy
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-066-exceptions-and-error-handling-strategywhen the site is running.
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
elsefor the code that must run only when nothing raised, andfinallyfor 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
@retrydecorator with backoff, retry only errors that a later attempt might survive, and give up with the cause preserved. - Log an exception properly with
logging.exceptionand 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
@retryhelper 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).bashfor the test runner (preinstalled on macOS and Linux).- Standard library only:
json,logging,sys,time,traceback. Nothing to install. Seerequirements/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 raiseFileNotFoundError,ValueError, andKeyErrorrespectively.python3 examples/swallowing.py <file>— the same work wrapped inexcept: pass. It always printsdoneand 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 tomain(), which reports it and exits 1. With no argument it prints usage and exits 2.cat triage.log— the loglogging.exceptionwrote: the message, the full traceback, and the chaining line that connects theRecordErroryou raised to theValueErrorthat 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@retrygives up and raisesDispatchErrorfrom the lastConnectionError.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
python3 examples/raw_triage.py examples/samples/no-such-file.jsonlends withFileNotFoundError: [Errno 2] No such file or directory: ...and exits 1.- The same command on
bad-severity.jsonlends withValueError: invalid literal for int() with base 10: 'high'; onmissing-field.jsonlit ends withKeyError: 'severity'. python3 examples/swallowing.py examples/samples/no-such-file.jsonl; echo $?printsdoneand then0— the failure you just saw, made invisible.python3 examples/triage.py examples/samples/intake.jsonlprintsadmitted 5 record(s), rejected 0and exits 0.python3 examples/triage.py examples/samples/missing-field.jsonl; echo $?printsrejected: line 2: missing field 'severity'to standard error, still summarizes the other two records, and exits0.python3 examples/triage.py examples/samples/no-such-file.jsonl; echo $?printserror: no such intake file: ...and exits1.python3 examples/triage.py examples/samples/bad-severity.jsonl triage.logthencat triage.logshowsERROR: rejected record on line 2, a full traceback, the lineThe above exception was the direct cause of the following exception, and the originalValueError.python3 examples/dispatch_demo.pyshows two backoff lines (0.05s,0.10s), then a success, then a chainedDispatchError.starter/traceback-notes.mdis filled in: three tracebacks pasted and all the questions answered.- Your completed
starter/triage.pybehaves identically to the reference. bash tests/run_tests.shends with0 failure(s).and exits0.
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
- Add a
--strictmode: a second run mode in which the firstRecordErroraborts the whole run with exit 1 instead of being logged and skipped. Decide where that decision belongs — the parser, the loop, ormain()— and write one sentence justifying it. - Add a
retry_onparameter 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. - 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. - Replace the two
print(..., file=sys.stderr)calls inmain()withlogging.error(...)plus alogging.StreamHandler, so one configuration controls both the file and the screen. Confirm the tests still pass. - Write
tests/test_handlers.pythat importstriageand asserts the same chaining behaviour with plainassertstatements, printsall tests passed, and exits 0 — then explain why thoseasserts are fine in a test file but would be wrong as input validation inparse_record.
Navigation
- 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:
logging.basicConfig(...)was never called, or was called after the first log record.basicConfigconfigures the root logger only once, and only if it has no handlers yet.- You called
logging.error(...)instead oflogging.exception(...).logging.exceptionis the one that attaches the traceback, and it must be called from inside anexceptblock. - You passed a log path in a directory that does not exist. The default is
triage.login 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.logby 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 raisesConnectionErroron a counter — there is no socket anywhere in this lab. -
Swallowed exceptions are a security bug, not just a tidiness problem.
examples/swallowing.pycatches 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:catchesBaseException, which includesKeyboardInterruptandSystemExit— so it can trap Ctrl-C and defeat a deliberate shutdown. It also catchesMemoryErrorand 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.exceptionwrites 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. -
assertis not a security check. Python'sassertstatements 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. Useif ...: raise ...for checks that must always run; keepassertfor 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, andtests/run_tests.shbefore running them. Running unread scripts is one of the most common ways developers get compromised.