fix(search): the API-sourced live search takes its slot under the lock too

Checked first and logged after search.list, requests sent together at the
last free slot all passed, and each spent 100 units of the shared budget.
Every live search now claims its slot (quota.claim_action) whatever the
source, and gives it back when it showed nothing. The cap counts the
zero-unit claims only, so search.list's own spend event is accounting,
not a second slot. log_action, the unlocked writer, goes.

Tests: the API source reads one page per request, is refused before the
call when the budget cannot pay, counts one slot per search and refunds a
failed one; the lock test now proves the count happens after the lock.
This commit is contained in:
2026-10-25 02:50:34 +02:00
parent 005784e291
commit ed51f5be7e
6 changed files with 133 additions and 55 deletions
+15 -23
View File
@@ -142,27 +142,20 @@ def search_youtube(
daily_limit = sysconfig.get_int(db, "search_daily_limit_per_user")
if daily_limit <= 0:
raise refused(409, "search_disabled")
claimed: int | None = None
if source == "api":
# The one search.list call logs this action's event itself (record_usage, attributed
# below), so here the cap is only checked: a claimed slot would count it twice.
if quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_SEARCH) >= daily_limit:
_cap_spent("search", daily_limit)
raise refused(429, "search_daily_limit", limit=daily_limit)
# The 100-unit budget pre-check only applies to the API source.
if not quota.can_spend(db, SEARCH_COST):
log.info(
"a live search was refused: the shared YouTube budget is spent",
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
)
raise refused(429, "quota_spent")
else:
# The check and the take are one step (`quota.claim_action`); a search that fails
# outright gives the slot back below.
claimed = quota.claim_action(user.id, quota.QuotaAction.VIDEOS_SEARCH, daily_limit)
if claimed is None:
_cap_spent("search", daily_limit)
raise refused(429, "search_daily_limit", limit=daily_limit)
# The 100-unit budget pre-check only applies to the API source.
if source == "api" and not quota.can_spend(db, SEARCH_COST):
log.info(
"a live search was refused: the shared YouTube budget is spent",
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
)
raise refused(429, "quota_spent")
# The check and the take are one step (`quota.claim_action`), whichever the source: checked
# first and logged after, requests sent together at the last free slot all passed — and with
# the API source each of them spent 100 units. A search that fails outright gives it back below.
claimed = quota.claim_action(user.id, quota.QuotaAction.VIDEOS_SEARCH, daily_limit)
if claimed is None:
_cap_spent("search", daily_limit)
raise refused(429, "search_daily_limit", limit=daily_limit)
# Channels this user has blocked are dropped before ingestion — never shown, never stored.
blocked = queries.blocked_ids(db, user)
@@ -230,8 +223,7 @@ def search_youtube(
)
except Exception:
# A search that showed the user nothing does not count against them.
if claimed is not None:
quota.refund_action(claimed)
quota.refund_action(claimed)
raise
ordered = collected[:limit]