failroute
Static detection of failure-routing anti-patterns in Python: the practice of converting an underlying failure into a success-like outcome at the wrong layer.
Failure-routing is the root cause behind some of the most insidious correctness bugs in real LLM/eval/agent codebases:
# Before — a judge API outage becomes a perfect "0.0 score" with no way to tell
try:
score = await llm_judge(prompt)
except Exception:
return 0.0 # ← silent fallback: failure looks like a legitimate low score
# After — the failure propagates; callers can route it to the right outcome
return await llm_judge(prompt)
What it detects
| Mode | Pattern |
|---|---|
no-action |
except ...: pass — the exception is discarded, callers never learn |
silent-fallback |
handler returns/assigns a constant (None, 0, 0.0, False, [], …) without re-raising |
masked-exception |
catch-all handler re-raises conditionally yet also falls through to a success-looking return |
name-shadowing |
except E as e: whose body rebinds e — Python deletes the binding at handler exit, so later uses raise NameError |
Findings are emitted as file:line: mode: message, or as JSON for CI.
Logging exemption (two tiers)
A handler that records the failure is informational, not silent — but what counts as a record depends on how wide the handler is:
- Catch-all handlers (
except:/except Exception:) must log at a severity worth reading (warning+). Adebugline or a bareprint(...)does not survive production triage, so it does not exempt. - Typed handlers name an anticipated failure mode; recording it at any
level (even
logger.info) is enough.
Usage
$ failroute path/to/file.py
$ failroute path/to/dir # recursive
$ failroute --repo . # skip .git/.venv/build/...
$ failroute --repo . --exclude tests/corpus # repeatable path exclusions
$ failroute --json --repo . | jq 'select(.mode=="silent-fallback")'
$ failroute --format sarif --output results.sarif --repo . # code scanning
$ failroute --threshold 5 # exit 1 when more than 5 findings
$ python -m failroute . # module form (no console script needed)
Exit codes: 0 clean, 1 findings above threshold, 2 usage error.
Output formats
$ failroute --format text path/ # default: file:line: mode: message
$ failroute --format json path/ # one JSON object per finding
$ failroute --format sarif --output scan.sarif path/ # SARIF 2.1.0
SARIF output plugs straight into GitHub code scanning via the
upload-sarif action, so findings appear inline on pull requests:
- run: failroute --format sarif --output results.sarif --repo .
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
Or use the bundled composite action, which installs failroute, scans, and uploads SARIF in one step:
- uses: feiiiiii5/failroute/action@main
with:
path: src
exclude: tests/corpus fixtures
threshold: "0"
Severity mapping: silent-fallback → error, no-action and
masked-exception → warning.
Suppressing findings
Reviewed-and-accepted handlers can be opted out with a line marker (the scanner honors both):
try:
return best_effort()
except Exception: # failroute: ignore - documented fallback semantics
return None
# pragma: no cover markers are honored as well (explicitly defensive code).
Examples that trip it
def classify(text): # no-action
try:
return model.predict(text)
except Exception:
pass # 💥 swallowed
def score(prompt): # silent-fallback
try:
return judge(prompt)
except Exception:
return 0.0 # 💥 outage == "0.0 score"
def fetch(url): # silent-fallback (assign)
data = None
try:
data = download(url)
except Exception:
data = {"items": []} # 💥 error looks like an empty result
return data
What it does not flag (by design)
except KeyboardInterrupt/except SystemExit— normally intentional.- Handlers that re-raise unconditionally without a fallback.
exceptbodies that log atwarning/errorand re-raise — the failure still propagates; we only flag the success-looking path.
Run failroute on its own checkout as a smoke test:
$ pip install -e .
$ failroute --repo . # expected: zero findings (self-hosting)
Benchmarks & validation
All numbers below are reproducible from this checkout; nothing here is copy-pasted from a run that cannot be re-executed.
Labelled corpus (precision / recall)
tests/corpus/ holds 19 hand-labelled exception handlers (10 positives across
all three modes, 9 negatives covering re-raise, log-and-raise, derived values,
dead code, opt-out markers, and non-fallback constants). Ground truth lives in
tests/corpus/manifest.json and was written from the semantics of each
fixture, independently of tool output.
corpus v1 TP=10 FP=0 FN=0 TN=9
precision=1.0 recall=1.0
Re-run: python tools/benchmark.py (also enforced by pytest).
What syntactic linters miss
Against the source packages of 8 real AI/eval repositories (garak,
inspect_ai, pydantic-ai, uqlm, trl, smolagents, deepteam, fickling),
failroute reported 647 findings; ruff's exception-handling rules
(S110 try-except-pass, S112 try-except-continue) reported 80, of which
70 overlap failroute's no-action mode. The remaining 390 findings are
silent-fallback / masked-exception handlers -- failures converted into
success-looking values -- a class syntactic rules cannot express by
construction.
Re-run: python tools/compare_ruff.py <repo> [<repo> ...].
Results are checked into bench/.
Development
$ pip install -e ".[test]"
$ pytest
$ ruff check .
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file failroute-0.3.0.tar.gz.
File metadata
- Download URL: failroute-0.3.0.tar.gz
- Upload date:
- Size: 22.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.13 {"installer":{"name":"uv","version":"0.11.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
544ee125de0e68b55eee18c5a967cfc37428a4931d1793c0b55aaf3b481d534e
|
|
| MD5 |
4743d54b08b673150b4ee411ba13923a
|
|
| BLAKE2b-256 |
7cb3e1aaf171bfdce3ea0fedf6ef6ffce9ee8ee8c3d77351c9f2c1e89e07a6c3
|
File details
Details for the file failroute-0.3.0-py3-none-any.whl.
File metadata
- Download URL: failroute-0.3.0-py3-none-any.whl
- Upload date:
- Size: 16.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.13 {"installer":{"name":"uv","version":"0.11.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6963936dea65df46353dbd05648036464eda569f33c385f6591114127b63b825
|
|
| MD5 |
7a23a29f18d7b5c2e4f0b7db3fdd564f
|
|
| BLAKE2b-256 |
0cf600bcdf2154ed08f60e13540ff24dfe49508d547e4d6fa47d848a3b98e286
|