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:
Eric Wagoner
2026-08-01 12:26:03 -04:00
parent d58568cceb
commit 4e1211feb6
6 changed files with 535 additions and 0 deletions
+110
View File
@@ -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")
+114
View File
@@ -0,0 +1,114 @@
import pytest
from bggpipe.models import (
BGGResponseError,
parse_collection,
parse_search,
parse_things,
)
SEARCH_XML = """<items total="2">
<item type="boardgame" id="13">
<name type="primary" value="CATAN"/><yearpublished value="1995"/>
</item>
<item type="boardgameexpansion" id="290448">
<name type="alternate" value="Wingspan: Europa"/>
</item>
</items>"""
THING_XML = """<items>
<item type="boardgame" id="266192">
<name type="primary" sortindex="1" value="Wingspan"/>
<name type="alternate" sortindex="1" value="Flügelschlag"/>
<yearpublished value="2019"/>
<statistics page="1"><ratings>
<owned value="123456"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" value="30"/>
<rank type="family" id="5497" name="strategygames" value="25"/>
</ranks>
</ratings></statistics>
<versions>
<item type="boardgameversion" id="465063">
<name type="primary" value="English edition"/>
<yearpublished value="2019"/>
<link type="boardgamepublisher" id="23202" value="Stonemaier Games"/>
<link type="language" id="2184" value="English"/>
</item>
<item type="boardgameversion" id="465064">
<name type="primary" value="German edition"/>
<yearpublished value="2019"/>
<link type="boardgamepublisher" id="22160" value="Feuerland Spiele"/>
<link type="language" id="2188" value="German"/>
</item>
</versions>
</item>
</items>"""
COLLECTION_XML = """<items totalitems="2">
<item objecttype="thing" objectid="13" subtype="boardgame" collid="101">
<name sortindex="1">Catan</name>
<yearpublished>1995</yearpublished>
<status own="1" wanttoplay="0"/>
</item>
<item objecttype="thing" objectid="266192" subtype="boardgame" collid="102">
<name sortindex="1">Wingspan</name>
<status own="0" wishlist="1"/>
<version><item type="boardgameversion" id="465063"/></version>
</item>
</items>"""
NOT_RANKED_XML = """<items>
<item type="boardgame" id="99999">
<name type="primary" value="Obscurity"/>
<statistics><ratings>
<owned value="12"/>
<ranks><rank type="subtype" id="1" name="boardgame" value="Not Ranked"/></ranks>
</ratings></statistics>
</item>
</items>"""
ERROR_XML = (
"<errors><error><message>Invalid username specified</message></error></errors>"
)
def test_parse_search():
results = parse_search(SEARCH_XML)
assert [r.bgg_id for r in results] == [13, 290448]
assert results[0].name == "CATAN"
assert results[0].year == 1995
assert results[1].type == "boardgameexpansion"
assert results[1].name_type == "alternate"
assert results[1].year is None
def test_parse_things_with_stats_and_versions():
(thing,) = parse_things(THING_XML)
assert thing.name == "Wingspan" # primary, not alternate
assert thing.owned == 123456
assert thing.rank == 30 # boardgame rank, not the family rank
assert len(thing.versions) == 2
english = thing.versions[0]
assert english.version_id == 465063
assert english.publishers == ("Stonemaier Games",)
assert english.languages == ("English",)
def test_parse_things_not_ranked_is_none():
(thing,) = parse_things(NOT_RANKED_XML)
assert thing.rank is None
assert thing.owned == 12
def test_parse_collection():
catan, wingspan = parse_collection(COLLECTION_XML)
assert (catan.object_id, catan.coll_id, catan.own) == (13, 101, True)
assert catan.version_id is None
assert wingspan.own is False # wishlist item must not count as owned
assert wingspan.version_id == 465063
def test_error_document_raises():
with pytest.raises(BGGResponseError, match="Invalid username"):
parse_collection(ERROR_XML)