Skip to content

← Failure by Design step 6 of 18

Medium Primitives

Bare except, blind except Exception, and where to catch

try/except Exception: pass around a database write is data loss that nobody discovers for a month. A bare except: in a worker loop means Ctrl-C does nothing and your deploy hangs until someone reaches for kill -9.

Both come from the same misunderstanding, so start there. A bare except: catches BaseException, which is strictly larger than Exception. The extra members are the ones you must never swallow:

  • KeyboardInterrupt — the operator asked you to stop.
  • SystemExitsys.exit() is in flight; swallowing it turns a clean shutdown into an infinite loop.
  • GeneratorExit — a generator is being closed; swallowing it is a RuntimeError waiting to happen.
  • and, in async code, asyncio.CancelledError, which since 3.8 is a BaseException specifically so that except Exception cannot eat it.

PEP 760, “No More Bare Excepts”, was withdrawn. Bare except: is legal Python forever, so enforcement is a linter’s job: ruff’s E722 for the bare form and BLE001 for blind except Exception.

Where to catch, not just what

The rule that survives review:

  • Narrowly, near the raise, when you can actually recover — you know which exception, and you know what to do about it.
  • Broadly, only at a boundary — a request handler, a worker loop, a main(). There, one blanket except Exception that logs with exc_info=True and converts the failure into an error response is not sloppy, it is the correct and necessary thing. A boundary that lets one bad request kill the process is worse than a boundary that catches too much.

run_all below is a boundary. It is supposed to be greedy — and greedy stops exactly at the Exception/BaseException line.

What to build

The starter contains a run_all that is subtly, plausibly wrong. Fix it.

def run_all(tasks: Sequence[Callable[[], None]]) -> list[tuple[int, Exception]]:

Run every task in order. Collect (index, exception) for each one that raises an Exception, and keep going — one bad task must not stop the batch. Anything that is a BaseException but not an Exception propagates immediately, and the failures collected so far are discarded along with it.

You do not need an isinstance check to express that. Say what you mean and the interpreter does the rest.

The probe

def solve(tasks: Sequence[Mapping[str, str]]) -> dict[str, object]:

Each spec is {} for a task that succeeds, or {"raises": <name>, "message": <text>} for one that raises RAISABLE[name](message). Build one callable per spec. Every task must append its own index to a shared list before it raises, so the result records what actually ran.

Return exactly these keys:

key value
"outcome" "completed" if run_all returned, "aborted" if a BaseException escaped it
"abort_type" the escaping exception’s class name, else None
"started" the list of indices that began executing, in order
"failures" for "completed": one {"index", "type", "message"} dict per collected failure, in order. For "aborted": []

Note what "started" proves. After a task at index 1 raises ValueError, the task at index 2 must still appear. After a task at index 1 raises KeyboardInterrupt, it must not.

Typing notes

list[tuple[int, Exception]] is doing real work: append a BaseException to it and mypy rejects the line. The starter’s bug is therefore a type error as well as a behavioural one — which is the whole argument for the gate. Fixing the annotation to BaseException to silence mypy makes the tests fail instead, and that is the honest signal.

str(KeyError("head")) is "'head'", quotes included. That is KeyError being KeyError, not a bug in your code.