skip to content

A route-optimisation job wraps a failure in a new exception and its logged stack silently truncates to the wrapper; why, and how do you keep the original frames?

level: seniorimportance: should knowfreq 38%

answer

  1. Nodes are added while it travels
  2. A new object starts empty
  3. The same function can appear twice
  4. One method grafts an old chain on
  5. Truncation is usually a logging fault

basics

~20 s

A brand-new exception starts with an empty traceback that only grows from the raise site outward, so the original frames are gone from it. Re-raise the same object to keep them, or transplant them with with_traceback, or let chaining carry the original alongside.

solid answer

~40 s

Traceback nodes are appended during **propagation**, not at construction: each frame the exception leaves prepends itself. Re-raising the *same* object — with a bare `raise`, or by raising the caught instance again — keeps its existing chain and simply adds the handler's frame, which is why the same function can legitimately appear twice in one rendered stack. Constructing a *new* exception starts a fresh chain from the raise site, so the frames that actually failed are not on it. Three fixes: re-raise the original; call `raise Wrapper(...).with_traceback(exc.__traceback__)` to graft the old chain onto the new exception; or rely on chaining, which keeps the original exception — with its own traceback — reachable and rendered separately. What must not happen is the wrapper being logged with `str()`, which drops the frames whichever route you chose.

code

python · 21 lines
python
import traceback

class RouteRejected(Exception):
    pass

def leg_cost(n):
    return 100 / n

def optimise(legs):
    return [leg_cost(n) for n in legs]

def run_job(legs):
    try:
        return optimise(legs)
    except ZeroDivisionError as exc:
        raise RouteRejected("leg has zero length").with_traceback(exc.__traceback__)

try:
    run_job([4, 0])
except RouteRejected as exc:
    print([f.name for f in traceback.extract_tb(exc.__traceback__)])

go deeper

for a junior

Remember the safe habit: inside a handler, a bare raise re-raises what you caught and keeps its stack, while raising something new starts a fresh one. Prefer re-raising until you know why you need a wrapper.

for a middle

Explain the mechanism — nodes are appended as the exception leaves each frame — and use it to predict what a wrapper's stack will and will not contain. Know with_traceback as the way to graft the old chain onto a new exception.

for a senior

Diagnose from the artefact: given a truncated production stack, say whether the frames were lost at the raise or at the log line, and name the fix on both sides. Be able to defend a wrapping boundary and show how the inner detail is still reachable.

for a principal

Set the convention for error boundaries across services: which layers may wrap, what the wrapper must carry, how stacks reach the error reporter, and how that is enforced — review rule, lint, or a test at each public entry point — so one team's helper cannot blind everyone else's on-call.

### The mechanism A traceback is built incrementally. When an exception is raised, `__traceback__` starts empty; as it leaves each frame the interpreter creates a node for that frame and links it at the **head** of the chain, since that frame is further out. The chain therefore records the path the exception took, and only that path. This one rule explains all the surprising cases. **Re-raising the same object.** A bare `raise` inside a handler continues the very same exception instance, which already owns a chain. Leaving the handler's frame prepends that frame, so after propagation the rendered stack shows the handler's function once for the `raise` line and once more, deeper in, for the line that originally called the failing code. Nothing is duplicated wrongly; you are seeing the same function at two different line numbers, which is exactly what happened. **Raising a new exception.** `raise RouteRejected("leg has zero length")` builds a fresh object with an empty chain. The frames of the failed computation are not on it. If the calling code logs only that wrapper's message, the stack visible to an on-call engineer stops at the wrapper — the failure looks like it originated inside your error-handling code. On a nightly route-optimisation job owned by an 11-person team, this is the classic morning mystery: the log names the wrapper and a line inside the retry helper, and the leg whose distance was zero appears nowhere. ### Keeping the frames **Re-raise the original.** If the handler has nothing new to say, a bare `raise` is the cheapest correct answer. If it needs to add context and you control the exception type, attaching information to the original object and re-raising it beats wrapping. **Transplant with `with_traceback`.** `raise RouteRejected("leg has zero length").with_traceback(exc.__traceback__)` sets the new exception's chain to the old one before the raise, so propagation then prepends the current frames onto the inherited chain. The rendered stack reads: the caller, the wrapping frame, then the original frames down to the real fault. `with_traceback` returns the exception itself, which is why it composes into the `raise`. **Chaining.** Raising inside an `except` block automatically records the exception being handled on the new one, and the default rendering prints both blocks. Nothing is lost — but the original stack lives on the *other* exception object, so any code that grabs only `new_exc.__traceback__` and formats that will still show a truncated view. Whether the two are rendered as one report or two is the chaining leaf's subject; what matters here is which object owns which frames. ### The failure mode to name Silent truncation is nearly always a *logging* fault, not a *raising* fault. Even a perfectly wrapped exception yields nothing if the boundary handler writes `logger.error(f"job failed: {exc}")`. The two halves have to agree: raise in a way that preserves the frames, and log in a way that renders them — `logging.exception(...)`, `exc_info=exc`, or the `traceback` module. ### Judgement Wrap when the caller genuinely should not see the inner exception type — a storage backend's error escaping through a domain API, for example — and keep the inner detail through chaining or a transplanted traceback so the operator still can. Do not wrap merely to add a sentence; a note on the original exception or a log line at the boundary does that without losing anything. And never re-raise a *new* exception in a `finally` or a cleanup path without checking what it does to the failure already in flight — replacing the exception there is the fastest way to lose the only stack that mattered. ### A cheap check Count frames. `len(traceback.extract_tb(exc.__traceback__))` on the exception your boundary logs tells you immediately whether the chain reaches the real fault or stops at your wrapper, and asserting on that count in a test is a durable guard against a refactor quietly reintroducing the truncation.

  • Why can the same function name appear twice in one rendered traceback?
    Because the handler's frame is added twice at different lines: once when the exception first propagated out of the call inside the `try`, and again when the re-raise leaves that frame. Both entries are real — read the line numbers, not the names. It is the normal signature of a bare `raise` or a `with_traceback` transplant, not a bug.
  • How would you write a test that catches this truncation before it ships?
    Provoke the failure through the public entry point, catch the exception the boundary raises, and assert that `traceback.extract_tb(exc.__traceback__)` contains the name of the function that really failed — or that the chained original does. Counting frames is brittle across versions; asserting a specific frame name is not.
  • When is wrapping still the right call despite the risk?
    When the inner exception type is an implementation detail the caller must not depend on — a storage or transport error escaping a domain API forces every caller to import that library's exception hierarchy. Wrap for the contract, then keep the detail reachable through chaining or a transplanted traceback so operators lose nothing.

saying these in an interview costs you the question

  • Thinks a new exception inherits the old frames automatically
  • Believes a bare raise resets or shortens the traceback
  • Says a repeated function name in a stack proves a bug
  • Assumes the traceback is captured when the exception is constructed
  • Blames the interpreter for truncation caused by logging str(exc)
  • Replaces the in-flight exception in a cleanup path without noticing

context