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
+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 {}