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:
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user