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.
108 lines
5.2 KiB
Python
108 lines
5.2 KiB
Python
"""A tiny in-process sliding-window rate limiter.
|
|
|
|
Generic groundwork: keyed by an arbitrary string (e.g. a client — `client_key`), so it can throttle any
|
|
endpoint. Single-worker uvicorn → in-memory state is sufficient; it resets on restart, which
|
|
is fine for abuse throttling (not for anything that must survive a deploy). Not shared across
|
|
processes — if we ever run multiple workers, swap the backing store for Redis behind the same
|
|
``allow()`` interface.
|
|
"""
|
|
|
|
import ipaddress
|
|
import threading
|
|
import time
|
|
|
|
from fastapi import Request
|
|
|
|
from app.config import settings
|
|
|
|
|
|
class RateLimiter:
|
|
"""At most `max_events` attempts per `window_seconds` per key — or, when each attempt passes a
|
|
`cost`, at most that much total cost (a byte budget counts bytes, not calls)."""
|
|
|
|
def __init__(self, max_events: float, window_seconds: float):
|
|
self.max_events = max_events
|
|
self.window = window_seconds
|
|
# key → (monotonic time, cost) of every counted attempt still inside the window
|
|
self._hits: dict[str, list[tuple[float, float]]] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
def reset(self) -> None:
|
|
"""Forget every recorded attempt. Each limiter is a module-level singleton, so a test suite
|
|
sharing one process would otherwise pool all of its attempts into a single window and
|
|
eventually trip an unrelated test with a 429."""
|
|
with self._lock:
|
|
self._hits.clear()
|
|
|
|
def exhausted(self, key: str) -> bool:
|
|
"""Whether ``key``'s window is already full (``allow`` would refuse it), WITHOUT counting an
|
|
attempt — for a limit that counts only some outcomes: ask it first, ``allow`` the ones that
|
|
count."""
|
|
cutoff = time.monotonic() - self.window
|
|
with self._lock:
|
|
return sum(c for t, c in self._hits.get(key, []) if t > cutoff) >= self.max_events
|
|
|
|
def allow(self, key: str, cost: float = 1) -> bool:
|
|
"""Record an attempt costing ``cost`` for ``key`` and return whether it is within the limit.
|
|
|
|
True -> within the cap (the attempt is counted).
|
|
False -> the attempt would pass the cap for the current window (attempt NOT counted, so a
|
|
blocked caller can't keep pushing the window forward)."""
|
|
now = time.monotonic()
|
|
cutoff = now - self.window
|
|
with self._lock:
|
|
hits = [(t, c) for t, c in self._hits.get(key, []) if t > cutoff]
|
|
if sum(c for _, c in hits) + cost > self.max_events:
|
|
self._hits[key] = hits
|
|
return False
|
|
hits.append((now, cost))
|
|
self._hits[key] = hits
|
|
# Opportunistic cleanup so abandoned keys don't accumulate unboundedly.
|
|
if len(self._hits) > 4096:
|
|
for k in list(self._hits):
|
|
fresh = [(t, c) for t, c in self._hits[k] if t > cutoff]
|
|
if fresh:
|
|
self._hits[k] = fresh
|
|
else:
|
|
del self._hits[k]
|
|
return True
|
|
|
|
|
|
def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
|
"""Parse an IP for comparison, unwrapping IPv4-mapped IPv6 (`::ffff:10.0.0.1` → `10.0.0.1`) so a
|
|
plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string."""
|
|
try:
|
|
ip = ipaddress.ip_address(s.strip())
|
|
# silent-ok: parsing an untrusted header value, where "not an IP" is a documented answer this
|
|
# function returns — and a line per request is what an attacker would aim for.
|
|
except ValueError:
|
|
return None
|
|
return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip
|
|
|
|
|
|
def client_key(request: Request) -> str:
|
|
"""Best-effort client identity for rate limiting: its IP, or for IPv6 the /64 the address sits in — a
|
|
subscriber is handed a whole /64, so keyed on the full address one client would have endless identities
|
|
to spread its attempts over. X-Forwarded-For is client-controlled, so we trust it
|
|
ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips
|
|
— the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS
|
|
the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a
|
|
client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the
|
|
proxy) is untrusted — we use its real socket IP, so it can't spoof its rate-limit identity.
|
|
|
|
SAFETY: this relies on `request.client` being the TRUE socket peer. uvicorn is started WITHOUT
|
|
`--proxy-headers` (see entrypoint.sh) precisely so it never rewrites `request.client` from XFF —
|
|
do NOT add that flag, or the trust check below would compare against a forgeable value."""
|
|
peer = request.client.host if request.client else "unknown"
|
|
client = peer
|
|
trusted = {_norm_ip(p) for p in settings.trusted_proxy_ip_set} - {None}
|
|
if _norm_ip(peer) in trusted:
|
|
xff = request.headers.get("x-forwarded-for")
|
|
parts = [p.strip() for p in (xff or "").split(",") if p.strip()]
|
|
if parts:
|
|
client = parts[-1]
|
|
ip = _norm_ip(client)
|
|
if isinstance(ip, ipaddress.IPv6Address):
|
|
return str(ipaddress.IPv6Network((ip, 64), strict=False))
|
|
return client
|