fix(logs): rule on every swallowed exception in the backend

114 exception handlers neither logged nor re-raised. The defect was never that
they are silent — plenty of them should be — it is that their silence said
nothing, so nobody could tell a decision from an oversight. Each one has now been
read. 44 got a line; the other 70 carry `# silent-ok: <why>`.

What the silent ones had been hiding, in the cases that got a line:
- A failed ffprobe sent every HDR file down the 8-bit pipeline, so playback
  degraded to a software transcode with nothing saying why; a failed audio probe
  collapsed a multi-audio file to one track and the player showed no language
  picker; a failed first-PTS probe left subtitles seconds out of sync.
- A subtitle sidecar that existed but would not parse made a PARTIAL track read as
  complete, permanently — the viewer loses every cue past the cached window.
- An unreadable crop/trim/cut-list in an edit spec was dropped, so the clip came
  out uncropped, untrimmed, or shorter than the one the user built.
- A dropped `max_height` downloaded at best quality against an explicit cap.
- A failed videos.list enrich made every candidate undroppable-from-judgement and
  therefore dropped: a working search rendered as "no results".
- An unusable password hash answered as "wrong password", locking the owner out
  and telling them they mistyped; a rotated TOKEN_ENCRYPTION_KEY turned every
  stored token into "not connected".
- A stored admin setting that would not coerce fell back to the env default while
  the settings page went on showing the saved value.
- A one-off InnerTube config failure pinned the whole process to the hard-coded
  fallback key, and searches return nothing when that goes stale.
- The HLS sweep reported 0 swept — like a clean run — when it could not list its
  own root, leaving every crashed session's segments on disk forever.

Four of them are counted rather than logged per item (dropped cues, dropped cut
list segments, unreclaimed GC files) and reported once per call, because the
alternative is one line per cue over a whole subtitle track.

ruff gains S110 and BLE001. Every blind `except Exception` that remains carries
`# noqa: BLE001 — <why>`, which is the point: catching everything is sometimes
right, and always worth a sentence. plex/sync.py's three best-effort backfill
catches now keep the traceback — a blind catch over a batch that fetches AND
writes could be Plex, the database or our own bug, and `%s` of the exception told
them apart badly. main.py's boot-time log-level read likewise.

logs.event's `code` is positional-only now: `event("x", code=...)` was a TypeError,
and `code` is exactly the word a caller reaches for when the thing that failed has
one of its own.

tests/test_swallowed_exceptions.py is the wider rule ruff cannot see: a handler
that neither logs nor re-raises must say why. Both cases mutation-checked — a
marker removed and a log line removed each fail it, naming the handler.
This commit is contained in:
2026-09-18 01:20:42 +02:00
parent 0cff964ff2
commit 6b0401b922
47 changed files with 792 additions and 96 deletions
+3 -1
View File
@@ -49,7 +49,9 @@ def _db_reachable() -> bool:
with probe.connect() as conn:
conn.execute(text("SELECT 1"))
return True
except Exception:
# silent-ok / noqa BLE001: the question is literally "can anything reach it", so every
# failure is the same answer — and this runs at import, before logging is configured.
except Exception: # noqa: BLE001
return False
finally:
probe.dispose()
@@ -0,0 +1,71 @@
"""E22.5 S3 — every swallowed exception has been ruled on.
A handler that neither logs nor re-raises is a decision: someone looked at it and decided the
failure is not worth a line. 114 of them were in `app/` at the start of this epic, and almost none
carried that decision anywhere — which means nobody could tell the deliberate ones from the ones
that were never thought about. That is the whole defect: not that they are silent, but that their
silence says nothing.
So the rule is not "log everything". It is: a silent handler carries `# silent-ok: <why>` on the
`except` line or the line above it. Ruff's S110 and BLE001 sit under this and catch the two narrow
shapes (`except: pass`, blind `except Exception`); this case covers the rest — the handler that
quietly returns a fallback, or `continue`s past one item of a loop.
"""
import ast
import pathlib
from app import logs
APP = pathlib.Path(logs.__file__).resolve().parent
_MARKER = "silent-ok"
_LOG_METHODS = frozenset(
{"debug", "info", "warning", "warn", "error", "exception", "critical", "log"}
)
def _is_log_call(node: ast.AST) -> bool:
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
return False
if node.func.attr not in _LOG_METHODS:
return False
base, name = node.func.value, ""
while isinstance(base, ast.Attribute):
name = f"{base.attr}.{name}"
base = base.value
if isinstance(base, ast.Name):
name = f"{base.id}.{name}"
return "log" in name.lower()
def _swallows(handler: ast.ExceptHandler) -> bool:
"""Neither reports nor re-raises. A nested `raise` counts: the caller still finds out."""
for node in ast.walk(handler):
if isinstance(node, ast.Raise) or _is_log_call(node):
return False
return True
def test_every_silent_handler_says_why_it_is_silent():
offenders = []
for path in sorted(APP.rglob("*.py")):
src = path.read_text(encoding="utf-8")
lines = src.splitlines()
for node in ast.walk(ast.parse(src)):
if not isinstance(node, ast.ExceptHandler) or not _swallows(node):
continue
# The marker may sit in the comment block attached above the `except`, on the
# `except` line itself, or anywhere inside the handler — all three are this handler
# and nothing else, so no justification can excuse a neighbour.
start = node.lineno - 1
while start > 0 and lines[start - 1].lstrip().startswith("#"):
start -= 1
end = node.end_lineno or node.lineno
if _MARKER in " ".join(lines[start:end]):
continue
offenders.append(f"{path.relative_to(APP)}:{node.lineno}")
assert offenders == [], (
f"{len(offenders)} exception handlers neither log nor re-raise and do not say why. Add a "
f"log line with an event code, or `# {_MARKER}: <why the failure is not worth a line>` — "
f"{offenders}"
)