fix(search): a search that never reached YouTube gets its slot back, and a busy one waits a bounded time

The slot was kept as soon as search.list was about to be called, so a search refused before it went out (no connection, no token) cost the user a slot for nothing. It is kept now only once the client recorded the call's units, or the request went out and its answer never came (YouTube may have counted it): YouTubeError says whether a transport failure was sent, and the client counts what it spent.

The process lock around the budget check and the call is waited for at most ten seconds; past that the search is refused as busy (search_busy, a retry can help) and gets its slot back, instead of parking a request worker behind every slow call before it. The lock test now pins one hold around both steps, and the busy test fails on a wait without a bound.
This commit is contained in:
2026-10-25 13:51:25 +01:00
parent 84db570951
commit 2e1934689f
6 changed files with 196 additions and 23 deletions
+34 -6
View File
@@ -65,7 +65,10 @@ _refunds = RateLimiter(max_events=REFUNDS_PER_HOUR, window_seconds=3600)
# The API source's budget check and the search.list call it pays for run one at a time: checked
# apart, searches sent together at the last 100 units all passed, and all spent. One process
# serves the API (a single uvicorn worker, as `ratelimit` says), so a process lock holds them all.
# A search waits this long for it, then is refused as busy: each waiter holds a request worker, and
# a slow YouTube (30 s a call at worst) would otherwise park them all behind one another.
_api_search = threading.Lock()
API_SEARCH_WAIT_SECONDS = 10.0
def _gather(
@@ -137,6 +140,14 @@ def _budget_spent() -> None:
)
def _busy() -> None:
"""Say that a live search was refused because other API searches held the line too long."""
log.info(
"a live search was refused: other searches held the line",
extra=logs.event("search.refused", reason="busy", waited=API_SEARCH_WAIT_SECONDS),
)
def _cap_spent(feature: str, limit: int) -> None:
"""Say that a request was refused for the per-user daily cap. The 429 is in the request line
either way; which of the caps produced it is not, and they are answered by different things —
@@ -189,20 +200,36 @@ def search_youtube(
# Channels this user has blocked are dropped before ingestion — never shown, never stored.
blocked = queries.blocked_ids(db, user)
# Whether search.list was called: YouTube charges a call it refuses too (the client records the
# units before it reads the status), so from then on the search has spent the shared budget.
# Whether search.list was paid for: YouTube charges a call it refuses too, so once the client
# recorded its units — or the request went out and no answer came back — the search has spent
# the shared budget. One that never reached YouTube (no connection, no token) has not.
charged = False
def fetch(token: str | None) -> dict:
nonlocal charged
if source != "api":
return search_scrape.search(term, continuation=token)
with _api_search, quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH):
if not _api_search.acquire(timeout=API_SEARCH_WAIT_SECONDS):
_busy()
raise refused(429, "search_busy")
try:
return paid_search(token)
finally:
_api_search.release()
def paid_search(token: str | None) -> dict:
nonlocal charged
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH):
if not quota.can_spend(db, SEARCH_COST):
_budget_spent()
raise refused(429, "quota_spent")
spent = yt.spent
try:
page = yt.search_videos(term, page_token=token)
except YouTubeError as exc:
charged = yt.spent > spent or exc.sent
raise
charged = True
return yt.search_videos(term, page_token=token)
return page
def candidates_of(items: list[dict], _collected: list[str]) -> list[dict]:
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
@@ -262,7 +289,8 @@ def search_youtube(
except Exception:
# A search that showed the user nothing does not count against them, unless search.list
# was already paid for: then the slot is what stops a failing search (a bogus cursor gets
# a 400 every time) from spending the shared budget without end.
# a 400 every time) from spending the shared budget without end. A busy refusal paid
# nothing and gives it back.
if not charged:
_give_back(claimed, "search", user.id)
raise
+30 -6
View File
@@ -30,12 +30,26 @@ class YouTubeError(Exception):
message is prose, it changes under us, and it is deliberately the poorer of the two here.
`path` is the API endpoint that ANSWERED with the failure (`playlistItems`); empty when no
endpoint did — the request never got through (`transport`) or the token step refused first."""
endpoint did — the request never got through (`transport`) or the token step refused first.
def __init__(self, message: str, *, reason: str = "", path: str = ""):
`sent` is a `transport` failure that came after the request may have gone out (a read timeout,
a dropped answer): nothing answered, but YouTube may have counted the call."""
def __init__(self, message: str, *, reason: str = "", path: str = "", sent: bool = False):
super().__init__(message)
self.reason = reason
self.path = path
self.sent = sent
# The transport failures that stop a request before it reaches YouTube: nothing went out.
_UNSENT = (
httpx.ConnectError,
httpx.ConnectTimeout,
httpx.PoolTimeout,
httpx.ProxyError,
httpx.UnsupportedProtocol,
)
def _error_reason(resp: httpx.Response) -> str:
@@ -162,6 +176,9 @@ def best_thumbnail(thumbnails: dict | None) -> str | None:
class YouTubeClient:
# The quota units this client has recorded: what its calls have spent of the shared budget.
spent: int = 0
def __init__(self, db, user: User):
self.db = db
self.user = user
@@ -189,9 +206,16 @@ class YouTubeClient:
return self._http.request(method, url, **kwargs)
except httpx.HTTPError as exc:
raise YouTubeError(
f"YouTube API unreachable ({exc.__class__.__name__}: {exc})", reason="transport"
f"YouTube API unreachable ({exc.__class__.__name__}: {exc})",
reason="transport",
sent=not isinstance(exc, _UNSENT),
) from exc
def _spend(self, units: int) -> None:
"""Record what a call YouTube answered cost — a refused one too: it is charged all the same."""
quota.record_usage(units)
self.spent += units
# --- auth ---
def _access_token(self) -> str:
tok = self.user.token
@@ -269,7 +293,7 @@ class YouTubeClient:
else:
headers["Authorization"] = f"Bearer {self._access_token()}"
resp = self._send("GET", f"{API_BASE}/{path}", params=p, headers=headers)
quota.record_usage(cost)
self._spend(cost)
if resp.status_code != 200:
raise _upstream_failure("GET", path, resp, expected)
return resp.json()
@@ -401,7 +425,7 @@ class YouTubeClient:
params={"id": subscription_id},
headers={"Authorization": f"Bearer {self._access_token()}"},
)
quota.record_usage(50)
self._spend(50)
if resp.status_code not in (200, 204):
raise _upstream_failure("DELETE", "subscriptions", resp)
@@ -431,7 +455,7 @@ class YouTubeClient:
def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict:
headers = {"Authorization": f"Bearer {self._access_token()}"}
resp = self._send(method, f"{API_BASE}/{path}", params=params, json=json, headers=headers)
quota.record_usage(50)
self._spend(50)
if resp.status_code not in (200, 204):
raise _upstream_failure(method, path, resp)
return resp.json() if resp.content else {}