Errors as Part of Program Design · practice
Preserve Useful Diagnostic Information
An
Carry the values, not just the words
A message is a
super().__init__(...) passes the message up so printing the exception still works, and the two attributes are there for code that wants the numbers. A handler can now say “you asked for 9, try 0 to 1” without unpicking a sentence.
This is worth doing exactly when a caller needs the values. If nothing will ever read error.index, the message alone is fine.
Do not lose the cause
Lesson 4 introduced raise ... from .... It matters most here, because the difference only shows up when something has gone wrong:
Without from, __cause__ is None, but Python still records the original automatically in __context__ and normally shows both exceptions with a “During handling…” connector. With from, the original is also the explicit __cause__, and the
Implicit context can come from intentional translation without from, or from an accidental failure inside a handler. Explicit cause removes that ambiguity.

Why prefer raise QuizError(...) from error over raise QuizError(...)?
The three ways information gets lost
Swallowing. except QuizError: pass deletes the event. Nothing knows it happened.
Flattening. except QuizError as error: raise QuizError("something went wrong") replaces the top-level message with a vague one. The original normally remains in the traceback as context, but callers now receive a less useful exception without the original attributes.
Logging instead of handling. Recording a failure and then continuing as though it did not happen is the same as swallowing, with a paper trail nobody reads. If you log and cannot continue safely, re-raise.
The bare raise keeps the active exception and its traceback intact. raise error raises the same raise QuizError(str(error)) creates a different exception and loses structured details unless you rebuild them deliberately.
What a good failure looks like
Put together, a diagnosable failure has four things:
| Diagnostic detail | Why it helps |
|---|---|
| A specific type | so a caller can catch just this |
| A message naming the | so a reader stops searching |
| Attributes carrying the values | so code can react without parsing |
| A cause, when translated | so the underlying event survives |
Not every error needs all four. A ValueError for an empty prompt needs the first two and nothing more. The point is that each one is a decision, and the default of “raise something and hope” makes all four badly.
The exercise builds an exception that carries its context, and a boundary that uses it.
Task
Make the failures diagnosable.
QuestionNotFound(index, available) keeps both values as attributes and passes a message up with super().__init__, so printing it reads no question at 9; this bank has 2.
InvalidResponse(prompt, response) does the same, keeping prompt and response, with a message naming both.
load_points(raw) translates int()'s failure into QuizError, keeping the original as the cause.
describe(error) turns a caught "<TypeName>: <message>", and appends " (caused by <CauseTypeName>)" when there is a cause. It reads __cause__, not the message text.
collect(bank, answers, log) is the boundary. For each refusal it appends describe(error) to the log and carries on; it returns the score. A TypeError is not a refusal, so it is logged and then re-raised with a bare raise, leaving the original