fix(search): a paid search keeps its slot, refunds are few, and the budget check holds the call
An API-sourced live search that reached search.list has spent 100 units (the client records them before it reads the status), so it keeps its daily slot: refunding it let a bogus cursor spend the shared budget without ever counting against the cap. A failure before the call still gives the slot back. Every refund is rationed too (5 an hour per user and list): a cursor is a free string, and a request built to fail would otherwise scrape YouTube from the server without end. The API source asks the budget again right before search.list, under one process lock with the call: checked apart, searches sent together at the last 100 units all passed and all spent.
This commit is contained in:
@@ -316,6 +316,18 @@ def test_a_list_that_failed_gives_its_slot_back(db, make_user, as_user, pages):
|
||||
assert _ids(client.get(f"/api/search/related/{SOURCE}")) == [_vid(1)]
|
||||
|
||||
|
||||
def test_failures_past_the_hours_refunds_keep_their_slot(db, make_user, as_user, pages):
|
||||
# A cursor is a free string: a request built to fail would otherwise read YouTube without end.
|
||||
user = make_user()
|
||||
sysconfig.set_value(db, "related_daily_limit_per_user", 50)
|
||||
n = search_routes.REFUNDS_PER_HOUR
|
||||
pages["pages"] = [ScrapeError("bad cursor") for _ in range(n + 1)]
|
||||
client = as_user(user)
|
||||
for _ in range(n + 1):
|
||||
assert client.get(f"/api/search/related/{SOURCE}?cursor=bogus").status_code == 422
|
||||
assert quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_RELATED) == 1
|
||||
|
||||
|
||||
def test_a_zero_limit_turns_related_videos_off(db, make_user, as_user, pages):
|
||||
user = make_user()
|
||||
sysconfig.set_value(db, "related_daily_limit_per_user", 0)
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
The scrape and search.list are replaced by pages the test hands out, and YouTube's videos.list by an
|
||||
offline enrich. What is held: every request takes a slot, a cursor's too; a search that failed
|
||||
outright gives its slot back; a zero limit turns live search off, which says so instead of "try
|
||||
again tomorrow"; a lookup that fails says why instead of answering "no results"; and the API source
|
||||
— 100 units a page — reads one page per request, is refused before the call when the shared budget
|
||||
cannot pay for it, and counts one slot per search though search.list logs its own event."""
|
||||
again tomorrow"; a lookup that fails says why instead of answering "no results"; only a few
|
||||
failures an hour get their slot back; and the API source — 100 units a page — reads one page per
|
||||
request, is refused before the call when the shared budget cannot pay for it (checked again under
|
||||
one lock with the call), counts one slot per search though search.list logs its own event, and
|
||||
keeps the slot of a search search.list was already paid for."""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -210,15 +212,73 @@ def test_the_api_source_is_refused_before_the_call_when_the_budget_cannot_pay(
|
||||
assert quota.actions_today(db, user.id, ACTION) == 0
|
||||
|
||||
|
||||
def test_an_api_search_that_failed_gives_its_slot_back(db, make_user, as_user, pages):
|
||||
def test_an_api_search_that_search_list_refused_keeps_its_slot(db, make_user, as_user, pages):
|
||||
# YouTube charges a refused call too (the client records the units first), so a search that
|
||||
# reached search.list has spent the shared budget: refunding it let a bogus cursor spend it all.
|
||||
user = make_user()
|
||||
_api(db)
|
||||
sysconfig.set_value(db, "search_daily_limit_per_user", 1)
|
||||
_FakeYT.api_pages = [
|
||||
YouTubeError("search -> 403", reason="quotaExceeded", path="search"),
|
||||
YouTubeError("search -> 400", reason="invalidPageToken", path="search"),
|
||||
_page(1),
|
||||
]
|
||||
client = as_user(user)
|
||||
assert client.get("/api/search/youtube?q=cats").status_code == 422
|
||||
assert client.get("/api/search/youtube?q=cats&cursor=bogus").status_code == 422
|
||||
assert quota.actions_today(db, user.id, ACTION) == 1
|
||||
refused = client.get("/api/search/youtube?q=cats")
|
||||
assert refused.status_code == 429
|
||||
assert refused.json()["detail"]["reason"] == "search_daily_limit"
|
||||
assert _FakeYT.api_calls == 1
|
||||
|
||||
|
||||
def test_an_api_search_the_budget_refused_at_the_call_gives_its_slot_back(
|
||||
db, make_user, as_user, pages, monkeypatch
|
||||
):
|
||||
user = make_user()
|
||||
_api(db)
|
||||
answers = iter([True, False]) # the pre-check passes; the budget is gone by the call
|
||||
monkeypatch.setattr(search_routes.quota, "can_spend", lambda _db, _u: next(answers))
|
||||
_FakeYT.api_pages = [_page(1)]
|
||||
resp = as_user(user).get("/api/search/youtube?q=cats")
|
||||
assert resp.status_code == 429
|
||||
assert resp.json()["detail"]["reason"] == "quota_spent"
|
||||
assert _FakeYT.api_calls == 0
|
||||
assert quota.actions_today(db, user.id, ACTION) == 0
|
||||
assert client.get("/api/search/youtube?q=cats").status_code == 200
|
||||
|
||||
|
||||
def test_the_budget_check_and_the_call_it_pays_for_hold_one_lock(
|
||||
db, make_user, as_user, pages, monkeypatch
|
||||
):
|
||||
# Checked apart, searches sent together at the last 100 units all passed, and all spent.
|
||||
user = make_user()
|
||||
_api(db)
|
||||
held: list[tuple[str, bool]] = []
|
||||
real_check = quota.can_spend
|
||||
real_call = _FakeYT.search_videos
|
||||
|
||||
def can_spend(db_, units):
|
||||
held.append(("check", search_routes._api_search.locked()))
|
||||
return real_check(db_, units)
|
||||
|
||||
def search_videos(self, q, page_token=None, order="relevance"):
|
||||
held.append(("call", search_routes._api_search.locked()))
|
||||
return real_call(self, q, page_token=page_token, order=order)
|
||||
|
||||
monkeypatch.setattr(search_routes.quota, "can_spend", can_spend)
|
||||
monkeypatch.setattr(_FakeYT, "search_videos", search_videos)
|
||||
_FakeYT.api_pages = [_page(1)]
|
||||
assert as_user(user).get("/api/search/youtube?q=cats").status_code == 200
|
||||
assert held == [("check", False), ("check", True), ("call", True)]
|
||||
assert not search_routes._api_search.locked()
|
||||
|
||||
|
||||
def test_failures_past_the_hours_refunds_keep_their_slot(db, make_user, as_user, pages):
|
||||
# A cursor is a free string: a request built to fail would otherwise read YouTube without end.
|
||||
user = make_user()
|
||||
sysconfig.set_value(db, "search_daily_limit_per_user", 50)
|
||||
n = search_routes.REFUNDS_PER_HOUR
|
||||
pages["pages"] = [ScrapeError("bad cursor") for _ in range(n + 1)]
|
||||
client = as_user(user)
|
||||
for _ in range(n + 1):
|
||||
assert client.get("/api/search/youtube?q=cats&cursor=bogus").status_code == 422
|
||||
assert quota.actions_today(db, user.id, ACTION) == 1
|
||||
|
||||
Reference in New Issue
Block a user