In a log-ingest worker, a `finally` block's flush raises while a parse error is already propagating — which error reaches the caller?
answer
- Two failures, one propagation slot
- The first one is not destroyed here
- The traceback still shows both
- Handlers upstream match the wrong type
- __context__, not __cause__
basics
~20 sThe cleanup error wins. The exception raised inside finally propagates, and the parse error survives only as its context, printed under "During handling of the above exception". Upstream handlers keyed on the original type stop firing.
solid answer
~50 sRaising inside `finally` **masks** rather than discards: the new exception propagates and the interpreter attaches the in-flight one to it implicitly as `__context__`, so the traceback still shows both, separated by "During handling of the above exception, another exception occurred". The real damage is to type dispatch — every `except` clause up the stack now matches the cleanup error. A handler meaning "malformed record, drop it" never fires, the `except OSError:` retry path fires instead, and a batch already partly written is re-sent, duplicating records at a 1,200-request-per-minute peak. Fix it by containing the cleanup: wrap it in its own `try`/`except` and log the full traceback there, or raise an `ExceptionGroup` when both failures genuinely matter. Note that PEP 765's 3.14 warning does not cover this case — it flags only `return`, `break` and `continue`.
code
python · 11 linesdef flush_batch():
try:
raise ValueError("record 41 is malformed")
finally:
raise OSError("connection reset while flushing")
try:
flush_batch()
except OSError as exc:
print(type(exc).__name__, "<-", type(exc.__context__).__name__)go deeper
Know that a finally block can itself raise, and that when it does the caller sees the cleanup error rather than the original. Reading the full chained traceback instead of only its last line is the habit to build.
Explain implicit chaining: the in-flight exception is attached as __context__ and printed under "During handling of the above exception". Be able to contrast that with __cause__ set by raise ... from.
Demonstrate the production consequence — type dispatch upstream changes, so the wrong recovery path runs — and give the containment pattern: cleanup wrapped in its own try/except that logs the traceback, or an ExceptionGroup when both failures matter.
Own the wider design: which exception types a service's retry layer is allowed to key on, whether cleanup is permitted to raise at all, and how idempotent writes make the system safe even when a masked error routes a batch down the wrong path.
This is exception **masking**, and it is a different failure from the one people usually associate with `finally`. A `return`, `break` or `continue` in a `finally` block *discards* the in-flight exception outright — it is gone, unlogged, unrecoverable. A `raise` in a `finally` block does not discard it: the new exception propagates, and the original is attached to it as `__context__`. ### What the caller actually gets ```python def flush_batch(): try: raise ValueError("record 41 is malformed") finally: raise OSError("connection reset while flushing") ``` The caller receives the `OSError`. The `ValueError` survives on `exc.__context__`, and the default traceback prints both, separated by: ``` During handling of the above exception, another exception occurred: ``` This is Python's *implicit* chaining: whenever an exception is raised while another one is being handled — and a `finally` block runs with an exception in flight — the interpreter records the older one in `__context__`. That is distinct from `__cause__`, which is set only when you write `raise New() from old` and prints "The above exception was the direct cause of the following exception". Implicit chaining is why the diagnosis is usually recoverable from logs: the root cause is in the traceback, just not at the top. ### Why it still hurts in production The damage is not to the traceback, it is to **type dispatch**. Every `except` clause between the failure and the top of the stack now sees the cleanup error's type, not the original's. In a log-ingest worker that means the layer above stops behaving the way it was designed to: * the handler written as `except ValueError:` — "malformed record, drop it and move on" — never fires; * the handler written as `except OSError:` — "transport blip, retry the batch" — fires instead; * so a batch that was *already partly written* before the parse error is retried in full at a 1,200-request-per-minute peak, and the tail of it lands twice. The visible symptom is a duplicated side effect with no corresponding error in the metrics that count `ValueError`s, which sends the investigation to the wrong subsystem. Worse, the shape is load-dependent: cleanup that only fails when a connection pool is exhausted masks nothing at low volume and masks everything at peak, so the bug appears exactly when the traffic makes it expensive. Note also that PEP 765's 3.14 `SyntaxWarning` does **not** cover this case. It flags `return`, `break` and `continue` leaving a `finally` block; a `raise` — or an ordinary call that happens to raise — is not a syntax-level pattern and is never flagged. This one is found by reading code, by review, or by the incident. ### Making cleanup unable to mask The rule is that cleanup must not change which exception the caller sees. Three practical shapes: **Contain the cleanup failure.** Wrap the cleanup in its own `try`/`except` and log it. The original propagates untouched, and the cleanup error is still recorded: ```python try: process(record) finally: try: flush_connection() except OSError: logging.getLogger("ingest").exception("cleanup flush failed") ``` Calling `logging.Logger.exception` inside an `except` block writes the full cleanup traceback, so nothing is lost — the information moves from the exception channel to the logging channel, which is where a secondary failure belongs. **Surface both deliberately.** When both failures genuinely matter to the caller, raise an `ExceptionGroup` (available since 3.11) built from the two exceptions, and let the caller destructure it with `except*`. This is honest about there being two problems, at the cost of a caller that must be written to expect a group. **Make cleanup incapable of failing.** Best where it is achievable: cleanup that closes an already-closed handle, decrements a counter, or releases an in-process resource should be written so there is nothing left to raise. ### The operational half of the answer Fixing the masking restores the right error type, but it does not undo the duplicated writes. A retry path that can re-send a partly-applied batch needs an idempotency key or per-record deduplication regardless of which exception it sees — masking merely made the retry fire in a case the designers never intended. A senior answer names both halves: cleanup that cannot change the exception type, and a write path that survives being retried. It is also worth adding an assertion to the incident's postmortem tooling: an alert on `__context__` being non-`None` at the top-level handler is a cheap, direct signal that some cleanup path is masking real failures somewhere in the service.
- How is this different from a `return` inside the same `finally` block?A `return` discards the in-flight exception outright — no propagation, no traceback, no chaining, and the caller sees a successful return. A `raise` keeps it: something still propagates, and the original is preserved on `__context__` and printed in the traceback. Masking is therefore diagnosable from logs; swallowing is not. Both break upstream `except` clauses, but only one leaves evidence.
- Which attribute holds the masked exception, and how does it differ from `__cause__`?`__context__`, set implicitly whenever an exception is raised while another is being handled, and rendered as "During handling of the above exception, another exception occurred". `__cause__` is set only by an explicit `raise New() from old`, renders as "The above exception was the direct cause of the following exception", and also sets `__suppress_context__`. Reading a chained traceback correctly means knowing which of the two you are looking at.
- Fixing the masking restores the right exception type — does it stop the duplicated writes?No. Masking made the retry path fire in a case it was never designed for, but any retry that can re-send a partly-applied batch needs an idempotency key or per-record deduplication regardless of which exception it sees. The senior answer names both halves: cleanup that cannot change the exception type, and a write path that survives being retried.
- How would you detect other places in a service where cleanup is masking real failures?Instrument the top-level handler: whenever it catches an exception whose `__context__` is not `None`, log or count that fact with both types. Non-`None` context at the boundary is a cheap, direct signal that something down the stack raised while another exception was in flight — usually cleanup — and it turns an invisible class of bug into a metric you can alert on.
It is like a paramedic tripping on the way out of the building: the emergency everyone dispatched for is still on record, but the call that reaches the desk is now about a sprained ankle, and the wrong team responds.
saying these in an interview costs you the question
- Says the original exception is lost without a trace
- Thinks both exceptions propagate to the caller at once
- Confuses __context__ with __cause__
- Assumes an upstream except ValueError still catches it
- Believes a finally block is not allowed to raise
- Expects Python 3.14 to warn about raising inside finally