fix(e5): deep-review survivors — TMDB key log leak, person-enrich rollback + timeout
Three verified deep-review findings (the 4th, the filter combobox a11y gap, is deferred to a dedicated session on the combobox architecture): - SEC (medium): TmdbClient sent the api_key as a URL query param, so an HTTP error's httpx exception string embedded the full URL incl. the key, which enrich_batch logged. _get now raises status+path only (never str(e)/the URL) and severs the secret-bearing cause with `from None`. Regression test asserts neither the status nor the network error path contains the key. - STATE (low): person_detail's lazy-enrich except now db.rollback()s, so a failed enrich_person commit can't poison the session and 500 the subsequent SELECTs (SQLAlchemy 2.x deactivates the tx on a commit-time flush failure and does not auto-reset it). Test locks that the page renders despite a poisoned enrich. - ABUSE (low): the request-path lazy person enrich now caps its TMDB client at an 8s timeout (was the 30s default) so a slow TMDB can't tie up a worker; TmdbClient gained a `timeout` param (batch keeps 30s). The fuller fix (move person enrichment off the request path) is logged for later.
This commit is contained in:
@@ -65,13 +65,15 @@ class TmdbNotConfigured(TmdbError):
|
||||
class TmdbClient:
|
||||
"""Bound to the configured api_key + language. Use as a context manager so the httpx client closes."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
def __init__(self, db: Session, timeout: float = 30.0):
|
||||
self.key = sysconfig.get_str(db, "tmdb_api_key")
|
||||
if not self.key:
|
||||
raise TmdbNotConfigured("TMDB API key is not configured")
|
||||
self.base = settings.tmdb_api_base.rstrip("/")
|
||||
self.language = sysconfig.get_str(db, "tmdb_language") or settings.tmdb_language
|
||||
self._http = httpx.Client(timeout=30.0)
|
||||
# `timeout` is caller-tunable so the request-path lazy person enrich can cap how long it may
|
||||
# block a worker thread (the batch job keeps the generous default).
|
||||
self._http = httpx.Client(timeout=timeout)
|
||||
self._last = 0.0
|
||||
|
||||
def __enter__(self) -> "TmdbClient":
|
||||
@@ -97,13 +99,17 @@ class TmdbClient:
|
||||
try:
|
||||
r = self._http.get(f"{self.base}{path}", params=p)
|
||||
except httpx.HTTPError as e:
|
||||
raise TmdbError(str(e)) from e
|
||||
# NEVER put str(e)/the URL in the message: the request URL carries `?api_key=<secret>` in
|
||||
# its query string, and this message is logged (enrich_batch). Report the type + path only.
|
||||
raise TmdbError(f"TMDB request error on {path}: {type(e).__name__}") from None
|
||||
if r.status_code == 404:
|
||||
return None
|
||||
try:
|
||||
r.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise TmdbError(str(e)) from e
|
||||
# Same reason: the HTTPStatusError string embeds the full URL incl. the api_key. Sever the
|
||||
# cause (`from None`) so no traceback can resurface it, and log only status + path.
|
||||
raise TmdbError(f"TMDB {path} returned {e.response.status_code}") from None
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError as e:
|
||||
@@ -447,7 +453,10 @@ def enrich_person(db: Session, person: PlexPerson) -> bool:
|
||||
if not person.tmdb_person_id or not sysconfig.get_str(db, "tmdb_api_key"):
|
||||
return False
|
||||
try:
|
||||
with TmdbClient(db) as tmdb:
|
||||
# This runs on the request thread (first person-page open), so cap the outbound TMDB call with
|
||||
# a short timeout — a slow/hanging TMDB must not tie up a worker for the default 30s. (The fuller
|
||||
# fix — moving person enrichment off the request path — is logged in ROADMAP under E5.)
|
||||
with TmdbClient(db, timeout=8.0) as tmdb:
|
||||
payload = tmdb.fetch_person(person.tmdb_person_id)
|
||||
except TmdbError:
|
||||
return False
|
||||
|
||||
@@ -165,6 +165,10 @@ def person_detail(
|
||||
try:
|
||||
plex_tmdb.enrich_person(db, person)
|
||||
except Exception: # noqa: BLE001 — enrichment is optional; the filmography still renders
|
||||
# A failed enrich_person commit leaves the session in a needs-rollback state; without this
|
||||
# the following SELECTs would raise PendingRollbackError and 500 the page (SQLAlchemy 2.x
|
||||
# deactivates the transaction on a commit-time flush failure and does NOT auto-reset it).
|
||||
db.rollback()
|
||||
log.warning("person bio enrich failed for %s", name, exc_info=True)
|
||||
movie_lib_ids, show_lib_ids = _content_libs(db)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user