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
+6 -1
View File
@@ -901,7 +901,12 @@ def _register_account(email: str, password: str, lang: str | None = None) -> Non
except IntegrityError:
# A concurrent registration for the same new email won the race between the pre-check
# above and this flush (the unique-email constraint fires). Same already-registered
# no-op as the pre-check hit — roll back and stop.
# no-op as the pre-check hit — roll back and stop. The line carries nothing: the
# answer to the caller is deliberately identical either way, and so is this.
log.info(
"a registration lost the race for its address",
extra=logs.event("auth.register.race_lost"),
)
db.rollback()
return
upsert_pending_invite(db, email) # admin-approval gate
+29 -4
View File
@@ -165,7 +165,14 @@ def discover_channels(
user.yt_channel_id = own
db.commit()
except YouTubeError as exc:
log.warning("discovery: own-channel resolve failed (user %s): %s", user.id, exc)
# No traceback anywhere in this file's YouTube handlers: `_upstream_failure` already
# logged the structured `youtube.api.error`, and the message carries method, path,
# status and reason. The stack under it is this `except` and the client call.
log.warning(
"discovery: own-channel resolve failed: %s",
exc,
extra=logs.event("channels.discovery.own_channel_failed", reason=exc.reason),
)
rows = _discovery_rows(db, user)
@@ -189,7 +196,11 @@ def discover_channels(
rows, key=lambda r: (-int(r[1]), r[0].title is None, (r[0].title or "").lower())
)
except YouTubeError as exc:
log.warning("discovery: channel enrich failed (user %s): %s", user.id, exc)
log.warning(
"discovery: channel enrich failed: %s",
exc,
extra=logs.event("channels.discovery.enrich_failed", reason=exc.reason),
)
db.rollback()
return [
@@ -297,7 +308,13 @@ def channel_detail(
db.commit()
channel = db.get(Channel, channel_id)
except YouTubeError as exc:
log.warning("channel detail enrich failed (%s): %s", channel_id, exc)
log.warning(
"channel detail enrich failed: %s",
exc,
extra=logs.event(
"channels.detail.enrich_failed", channel_id=channel_id, reason=exc.reason
),
)
db.rollback()
sub = db.execute(
select(Subscription).where(
@@ -581,7 +598,15 @@ def subscribe(
try:
apply_channel_details(db, yt.get_channels([channel_id]))
except YouTubeError as exc:
log.warning("subscribe: stub enrich failed (%s): %s", channel_id, exc)
log.warning(
"subscribe: stub enrich failed: %s",
exc,
extra=logs.event(
"channels.subscribe.enrich_failed",
channel_id=channel_id,
reason=exc.reason,
),
)
db.add(
Subscription(
user_id=user.id,
+2
View File
@@ -587,6 +587,8 @@ def _clean_url(raw) -> str | None:
# authority. This one already has a "not a plausible URL" answer, so the typo belongs in it.
try:
parsed = urlparse(url)
# silent-ok: a typo in a link someone pasted. None is this function's "not a plausible URL"
# answer, the caller already has one, and any line here would carry the URL itself.
except ValueError:
return None
if not parsed.netloc:
+4
View File
@@ -256,6 +256,8 @@ def set_video_state(
db.delete(row)
try:
db.commit()
# silent-ok: the documented lost race — a concurrent player checkpoint already removed
# or moved this row. The state the caller asked for is the state that holds either way.
except StaleDataError:
db.rollback()
return {"video_id": video_id, "status": "new"}
@@ -298,6 +300,7 @@ def clear_video_state(
db.delete(row)
try:
db.commit()
# silent-ok: the row went under the delete, which is the outcome the delete wanted.
except StaleDataError:
db.rollback()
return {"video_id": video_id, "status": "new", "position_seconds": 0}
@@ -355,6 +358,7 @@ def set_video_progress(
# have removed/updated this row already — tolerate the lost race.
try:
db.commit()
# silent-ok: exactly the race the comment above describes; the response stays true.
except StaleDataError:
db.rollback()
return {"video_id": video_id, "position_seconds": 0}
+2
View File
@@ -449,6 +449,8 @@ async def messages_ws(ws: WebSocket) -> None:
try:
while True:
await ws.receive_text()
# silent-ok: the ordinary way a chat socket ends — a closed tab. The `finally` below is the
# whole handling, and nothing failed.
except WebSocketDisconnect:
pass
finally:
+61 -8
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import logs
from app.auth import current_user
from app.db import get_db
from app.models import (
@@ -30,6 +31,8 @@ from app.plex.lookups import by_rating_key
from app.routes.plex._shared import _norm_lang, item_or_404
from app.routes.plex.collections import _collection_source
log = logs.get_logger(__name__)
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -65,7 +68,21 @@ def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]:
related_f = ex.submit(plex.related, rating_key) # swallows its own errors → []
meta = meta_f.result() or {}
related_raw = related_f.result()
except (PlexError, PlexNotConfigured):
except PlexNotConfigured:
# silent-ok: not a failure but a state — this instance has no Plex server, and the page
# is meant to render from the local rows alone.
return {}, []
except PlexError as exc:
# The detail page still renders, from the rows the sync left behind: no cast, no related
# strip, no rich detail. Indistinguishable on screen from a show that simply has none.
log.warning(
"live Plex enrichment failed for a show",
extra=logs.event(
"plex.detail.show_enrich_failed",
rating_key=rating_key,
reason=type(exc).__name__,
),
)
return {}, []
# Don't cache an EMPTY related list: plex.related() swallows a transient failure as [] (same as a
# genuinely no-related show), so caching it would serve an empty "related" section for the whole TTL
@@ -145,12 +162,26 @@ def show_detail(
sh.people_enriched_at = datetime.now(timezone.utc)
try:
db.commit()
except Exception:
except Exception: # noqa: BLE001 — a failed enrich must not fail the page
# The rollback un-does the enrichment AND the `people_enriched_at` stamp, so the
# next view tries again — but if the commit keeps failing nobody ever hears, and
# the cast section stays empty for good.
log.warning(
"the show enrichment could not be committed",
extra=logs.event(
"plex.detail.enrich_commit_failed", rating_key=sh.rating_key, kind="show"
),
)
db.rollback()
except Exception:
except Exception: # noqa: BLE001 — the local episode list must render whatever Plex does
# Best-effort only — the local episode list is already assembled, so any live-Plex failure
# (not configured, HTTP/JSON shape) must degrade, not 500.
pass
# (not configured, HTTP/JSON shape) must degrade, not 500. With the traceback, because what
# is left in here after the Plex errors above are handled is our own shape bugs.
log.warning(
"the live part of a show page could not be built",
extra=logs.event("plex.detail.show_extras_failed", rating_key=sh.rating_key),
exc_info=True,
)
imdb_id = sh.imdb_id
return {
@@ -295,6 +326,14 @@ def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
try:
i = keys.index(it.rating_key)
except ValueError:
# An episode missing from its own show's ordered list — the two came out of the same sync,
# so this is a gap in it. The page loses its previous/next arrows and says nothing.
log.info(
"an episode is not in its show's episode list; no previous/next",
extra=logs.event(
"plex.detail.episode_nav_missing", rating_key=it.rating_key, show_id=it.show_id
),
)
return None, None
return (keys[i - 1] if i > 0 else None), (keys[i + 1] if i < len(keys) - 1 else None)
@@ -366,8 +405,14 @@ def item_detail(
"text": codec not in subtitles.BITMAP_SUBS,
}
)
except Exception:
pass # live extras (markers/streams) are best-effort; any live-Plex failure must degrade, not 500 — playback works without them
except Exception: # noqa: BLE001 — playback works without the extras; never 500 here
# Best-effort, but not free: what this drops is the subtitle stream list and the intro
# markers, so the player offers no subtitles and no skip button — and looks like a file
# that has neither.
log.warning(
"the live extras for an item could not be fetched",
extra=logs.event("plex.detail.extras_failed", rating_key=it.rating_key, kind=it.kind),
)
# Lazy enrichment (movies only): the live fetch above carries the FULL cast + rich detail (the sync
# only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable
@@ -380,7 +425,15 @@ def item_detail(
it.people_enriched_at = datetime.now(timezone.utc)
try:
db.commit()
except Exception:
except Exception: # noqa: BLE001 — a failed enrich must not fail the page
# Same as the show path above: the info page then serves the three names the listing
# sync stored, and the full cast it just fetched is thrown away unreported.
log.warning(
"the movie enrichment could not be committed",
extra=logs.event(
"plex.detail.enrich_commit_failed", rating_key=it.rating_key, kind="movie"
),
)
db.rollback()
hu = lang == "hu"
+13
View File
@@ -130,6 +130,13 @@ def _ingest_candidates(
apply_channel_details(db, details)
db.commit()
except YouTubeError:
# The stubs remain and the details can be backfilled later — but until they are, those
# channels sit in the results with no thumbnail and no subscriber count, looking like
# channels that have neither.
log.warning(
"the channels found by a search could not be enriched",
extra=logs.event("search.channel_enrich_failed", channels=len(new_channel_ids)),
)
db.rollback() # details can be backfilled later; the stubs remain
# 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO
@@ -159,6 +166,12 @@ def _ingest_candidates(
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
detail_by_id = {it["id"]: it for it in yt.get_videos(ids)}
except YouTubeError:
# Every candidate then stays unenriched, and an unenriched video cannot be judged live or
# Short — so it is dropped. A working search returns nothing and reads as "no results".
log.warning(
"the videos found by a search could not be enriched",
extra=logs.event("search.video_enrich_failed", candidates=len(ids)),
)
detail_by_id = {}
enriched_at = datetime.now(
timezone.utc