A blanket `except Exception` swallows an ImportError and degrades into a misleading business error
A blanket except Exception: around an import-and-call block catches a broken import path the same way it catches a flaky network call — and the failure that reaches the user is whichever message the except block happens to write, not the truth of what broke.
The signal that gives it away
- A feature that "always fails" — not intermittently, not under load, but on every single call, from the very first time it ran. That 100%-failure-rate-since-birth shape is the strongest tell: a genuine runtime error (timeout, flaky network, rate limit) is *sometimes* wrong; a broken import is *always* wrong, because it never succeeded even once.
- The user-facing message names a plausible-but-wrong business cause ("token expired," "connection refused," "service unavailable") and the obvious remedy (reconnect, retry, re-authenticate) does nothing — the user or an on-call engineer loops through the suggested fix repeatedly with zero effect, because the real cause has nothing to do with the message shown.
- The log line for the failure is a low-severity warning, not an error with a stack trace — because whoever wrote the except Exception block was thinking about a legitimate, recoverable runtime condition (network hiccup) and logged accordingly. A genuine programming error (broken import, typo'd attribute) buried at warning severity, with no traceback, is easy to miss in triage precisely because it looks routine.
- Reading the log message itself often gives it away directly: something in the shape of No module named 'X' or module 'X' has no attribute 'Y' sitting *inside* a warning whose surrounding text talks about tokens, connections, or timeouts. The exception text and the log's narrative don't match — that mismatch is the signal.
How to exploit it (diagnose, then fix)
1. Grep for the catch-all first, don't start from the symptom. grep -rnE "except Exception" services/<area>/ | grep -v "tests/\|__pycache__" — every hit is a candidate for hiding exactly this class of bug. Don't wait for a bug report to go looking; audit proactively, because a broken-since-birth import sits there silently until someone happens to read the actual exception text.
2. For each hit, ask "what am I actually expecting to fail here?" and narrow the catch to that family only:
- Network → httpx.HTTPError, requests.RequestException, TimeoutError (the built-in — on Python 3.11+ asyncio.TimeoutError is just an alias for it, so new code should reach for the built-in directly rather than the asyncio-qualified name)
- Parsing → json.JSONDecodeError, ValueError
- Cache/store → the driver's own error class (e.g. redis.exceptions.RedisError)
- DB → sqlalchemy.exc.SQLAlchemyError, or the driver's native error class
- Filesystem → OSError, FileNotFoundError
3. Never fold these into the same catch, no matter how tempting the one-liner is — they are programming errors, not runtime conditions, and should crash loud instead of degrading quiet:
- ImportError / ModuleNotFoundError — a broken import path. Confirmed against the standard library's own exception hierarchy: both derive from Exception, so a bare except Exception catches them exactly as readily as it catches a timeout — nothing in the language distinguishes them for you (see provenance). ModuleNotFoundError is itself a subclass of ImportError, raised specifically for "module not found" as opposed to other import failures.
- AttributeError, NameError, TypeError — typos, wrong object shape, wrong arity.
4. When you do narrow the catch, upgrade the log call too. logger.exception(...) (not logger.warning(f"...{e}")) on whatever you do keep inside the catch, so the next occurrence comes with a full traceback instead of a one-line summary that may not even name the failing call site.
5. Quick test that proves the fix. Narrow one except Exception to the specific errors you listed in step 2, then run the happy path end-to-end. If a *new*, previously invisible crash appears, you've just surfaced a latent bug that had been silently degrading in production — that's the fix working, not a regression.
6. Immediate patch vs. structural fix are two different commits. The immediate patch is fixing the one broken import path you found. The structural fix is narrowing the except at that call site (and everywhere the audit in step 1 turned up) so the *next* unrelated programming error at that line also surfaces loud instead of being silently absorbed by the same broad catch — fixing today's import bug without narrowing the catch leaves the exact same blast radius open for tomorrow's typo.
Concrete shape of the bug and its fix, reconstructed generically:
```python # Before — broad catch hides a structural bug try: from integration_client import get_token # missing package prefix → ImportError, every call token = await get_token(session, validate=True) except Exception as e: # catches the ImportError too logger.warning(f"token preflight skipped: {e}") token = None if not token: return {"error": "token_expired", ...} # caller sees this, not the real cause
# After — narrow catch, correct import path import httpx try: from tools.integration_client import get_token # correct path token = await get_token(session, validate=True) except (httpx.HTTPError, TimeoutError) as e: # only what's genuinely expected at runtime logger.warning(f"token preflight failed (network): {e}") token = None # ImportError / AttributeError propagate → crash visible → fixed immediately, not degraded ```
What doesn't work
- Fixing only the broken import and leaving the catch broad. The import bug is one instance of a structural hole; leaving except Exception in place means the *next* AttributeError or NameError at that call site gets the identical silent-degrade treatment. Narrow the catch as part of the same fix, not as a follow-up someone might get to.
- Trusting the log severity or the surrounding narrative as a proxy for what actually broke. A warning-level message with plausible business language ("token skipped," "preflight failed") can be sitting directly on top of a hard programming error. The message the on-call engineer sees describes what the except block's author *expected* to go wrong, not necessarily what did.
- Treating "the remedy the error message suggests" as diagnostic signal. If reconnecting/retrying/re-authenticating does nothing across multiple attempts, that is itself evidence the stated cause is wrong — not evidence to try harder at the suggested remedy. A broken-since-birth import can't be fixed by anything the user does.
- Removing the try/except entirely as an overcorrection. The goal is precision, not zero error handling — a legitimate runtime failure (a real network timeout) still deserves a graceful degrade path. The fix is narrowing *which* exceptions trigger that path, not deleting the path.
- Wrapping genuinely internal calls in `try/except` "just to be safe." Defensive catches around calls to your own trusted internal code add noise without adding safety, and are exactly where a catch-all like this tends to get introduced in the first place; validate at the actual system boundary (external API, user input) instead of at every internal call site.
- Accepting `except Exception:` as fine because "it's just a background task." That's the one legitimate exception to the rule — but only if it's genuinely a non-critical, fire-and-forget task *and* it logs with logger.exception() (full traceback) rather than a bare .warning(f"{e}"). Reaching for the same blanket catch on a path the user is actively waiting on is not the same case.
Related
- A catch-all except wrapped around a webhook handler swallows the 401 it deliberately raised, and answers 200 — the same root habit in a different shape: there, the broad catch swallows a *deliberately raised* HTTP rejection and turns a legitimate 401 into a false 200, instead of swallowing an accidental ImportError.
- Some exceptions stringify to an empty string — logging only `str(e)` hides the real error — the companion logging mistake: even once you narrow the catch correctly, str(e) alone can render as an empty string for some exception classes, so log type(e).__name__ alongside it, not just the interpolated exception.
- The tool stays 'running' for hours: a synchronous LLM call with no timeout blocks the event loop — a related way "no exception raised" gives false confidence, from the opposite direction: here a broad catch hides an exception that *did* fire; there, nothing fires at all, so no catch — broad or narrow — ever gets a chance to run.
- Seven verified gotchas in a pause/resume-native workflow framework's primitives (checkpoint, resume_inputs, compaction, disconnect) — a sharper variant seen in a pause/resume framework: an overly broad catch there can swallow the framework's own internal "pause" control-flow signal, breaking the pause mechanism itself, not just hiding a bug.
Verified against
21 claims checked against these sources
What links here
Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.