BGG API client: disk cache, rate limiting, 202-queue retry, XML parsing
httpx client with injectable clock/sleep/rng for testability. Successful responses cached under data/bgg_cache/ keyed by endpoint+params; 202 retries follow the spec schedule (2/5/10/30s, give up after 5); 429/503 get jittered exponential backoff; consecutive requests are spaced rate_limit_seconds apart. Parsers (via defusedxml, per security hook) cover search, thing (+stats/+versions), and collection, including the Not Ranked and error-document cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from bggpipe.bgg_client import BGGClient, BGGQueueTimeout, cache_key
|
||||
|
||||
SEARCH_XML = (
|
||||
'<items total="1"><item type="boardgame" id="13">'
|
||||
'<name type="primary" value="CATAN"/><yearpublished value="1995"/>'
|
||||
"</item></items>"
|
||||
)
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 1000.0
|
||||
self.sleeps: list[float] = []
|
||||
|
||||
def monotonic(self) -> float:
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
self.sleeps.append(seconds)
|
||||
self.now += seconds
|
||||
|
||||
|
||||
def make_client(tmp_path, responses, clock=None):
|
||||
"""Client whose transport pops canned (status, body) responses."""
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request)
|
||||
status, body = responses.pop(0)
|
||||
return httpx.Response(status, text=body)
|
||||
|
||||
clock = clock or FakeClock()
|
||||
client = BGGClient(
|
||||
cache_dir=tmp_path / "cache",
|
||||
transport=httpx.MockTransport(handler),
|
||||
sleep=clock.sleep,
|
||||
monotonic=clock.monotonic,
|
||||
rng=random.Random(42),
|
||||
)
|
||||
return client, calls, clock
|
||||
|
||||
|
||||
def test_caches_successful_responses(tmp_path):
|
||||
client, calls, _ = make_client(tmp_path, [(200, SEARCH_XML)])
|
||||
first = client.get_xml("search", {"query": "catan", "type": "boardgame"})
|
||||
second = client.get_xml("search", {"query": "catan", "type": "boardgame"})
|
||||
assert first == second == SEARCH_XML
|
||||
assert len(calls) == 1 # second call served from disk
|
||||
cached = (
|
||||
tmp_path
|
||||
/ "cache"
|
||||
/ cache_key("search", {"query": "catan", "type": "boardgame"})
|
||||
)
|
||||
assert cached.exists()
|
||||
|
||||
|
||||
def test_202_retries_with_spec_backoff_then_succeeds(tmp_path):
|
||||
client, calls, clock = make_client(
|
||||
tmp_path, [(202, ""), (202, ""), (200, SEARCH_XML)]
|
||||
)
|
||||
xml = client.get_xml("collection", {"username": "someone", "own": "1"})
|
||||
assert xml == SEARCH_XML
|
||||
assert len(calls) == 3
|
||||
assert clock.sleeps == [2.0, 5.0] # spec schedule between attempts
|
||||
|
||||
|
||||
def test_202_gives_up_after_five_attempts(tmp_path):
|
||||
client, calls, clock = make_client(tmp_path, [(202, "")] * 5)
|
||||
with pytest.raises(BGGQueueTimeout):
|
||||
client.get_xml("collection", {"username": "someone", "own": "1"})
|
||||
assert len(calls) == 5
|
||||
assert clock.sleeps == [2.0, 5.0, 10.0, 30.0]
|
||||
# nothing cached on failure
|
||||
assert not (tmp_path / "cache").exists()
|
||||
|
||||
|
||||
def test_429_backs_off_with_jitter(tmp_path):
|
||||
client, calls, clock = make_client(tmp_path, [(429, ""), (200, SEARCH_XML)])
|
||||
client.get_xml("search", {"query": "catan"})
|
||||
assert len(calls) == 2
|
||||
(backoff,) = clock.sleeps
|
||||
assert 2.0 <= backoff <= 3.0 # base 2s, up to +50% jitter
|
||||
|
||||
|
||||
def test_rate_limit_spaces_consecutive_requests(tmp_path):
|
||||
client, calls, clock = make_client(tmp_path, [(200, SEARCH_XML), (200, SEARCH_XML)])
|
||||
client.get_xml("search", {"query": "catan"})
|
||||
client.get_xml("search", {"query": "wingspan"})
|
||||
# second request must wait the full 2s window (fake clock: no time passed)
|
||||
assert clock.sleeps == [2.0]
|
||||
|
||||
|
||||
def test_http_error_raises(tmp_path):
|
||||
client, _, _ = make_client(tmp_path, [(500, "boom")])
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
client.get_xml("search", {"query": "catan"})
|
||||
|
||||
|
||||
def test_cache_key_stable_and_filename_safe(tmp_path):
|
||||
key1 = cache_key("search", {"query": "Café & Krieg?", "type": "boardgame"})
|
||||
key2 = cache_key("search", {"type": "boardgame", "query": "Café & Krieg?"})
|
||||
assert key1 == key2 # param order must not matter
|
||||
assert "/" not in key1 and "?" not in key1 and key1.endswith(".xml")
|
||||
Reference in New Issue
Block a user