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
co-authored by Claude Fable 5
parent d58568cceb
commit 4e1211feb6
6 changed files with 535 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
"""BGG XML API2 client: disk cache, rate limiting, 202-queue and 429/503 retry.
Every successful response is cached under data/bgg_cache/ keyed by
endpoint + params, so re-runs never re-hit the API. The rate limiter
guarantees ≤1 request every rate_limit_seconds to any BGG endpoint.
"""
from __future__ import annotations
import hashlib
import random
import re
import time
from collections.abc import Callable, Iterable
from pathlib import Path
from urllib.parse import urlencode
import httpx
from bggpipe.models import (
CollectionItem,
SearchResult,
ThingDetails,
parse_collection,
parse_search,
parse_things,
)
BASE_URL = "https://boardgamegeek.com/xmlapi2"
QUEUE_BACKOFF = (2.0, 5.0, 10.0, 30.0) # sleeps between the 5 attempts (spec)
MAX_ATTEMPTS = 5
_UNSAFE = re.compile(r"[^A-Za-z0-9._=,-]+")
class BGGQueueTimeout(Exception):
"""BGG kept answering 202 (or throttling) past the retry budget."""
def cache_key(endpoint: str, params: dict[str, str]) -> str:
query = urlencode(sorted(params.items()))
digest = hashlib.md5(f"{endpoint}?{query}".encode()).hexdigest()[:10]
slug = _UNSAFE.sub("-", query)[:80].strip("-")
return f"{endpoint}_{slug}_{digest}.xml"
class BGGClient:
def __init__(
self,
cache_dir: Path,
rate_limit_seconds: float = 2.0,
transport: httpx.BaseTransport | None = None,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
rng: random.Random | None = None,
) -> None:
self.cache_dir = cache_dir
self._rate = rate_limit_seconds
self._sleep = sleep
self._monotonic = monotonic
self._rng = rng or random.Random()
self._last_request: float | None = None
self._http = httpx.Client(
base_url=BASE_URL,
timeout=30.0,
headers={"User-Agent": "bggpipe/0.1 (shelf-collection pipeline)"},
transport=transport,
)
def _throttle(self) -> None:
if self._last_request is not None:
wait = self._rate - (self._monotonic() - self._last_request)
if wait > 0:
self._sleep(wait)
def get_xml(self, endpoint: str, params: dict[str, str]) -> str:
"""Fetch one endpoint, serving from and filling the disk cache."""
cache_path = self.cache_dir / cache_key(endpoint, params)
if cache_path.exists():
return cache_path.read_text()
for attempt in range(MAX_ATTEMPTS):
self._throttle()
response = self._http.get(f"/{endpoint}", params=params)
self._last_request = self._monotonic()
if response.status_code == 202:
if attempt < MAX_ATTEMPTS - 1:
self._sleep(QUEUE_BACKOFF[min(attempt, len(QUEUE_BACKOFF) - 1)])
continue
if response.status_code in (429, 503):
if attempt < MAX_ATTEMPTS - 1:
backoff = 2.0 * (2**attempt) * (1 + self._rng.uniform(0, 0.5))
self._sleep(backoff)
continue
response.raise_for_status()
self.cache_dir.mkdir(parents=True, exist_ok=True)
cache_path.write_text(response.text)
return response.text
raise BGGQueueTimeout(
f"BGG did not serve /{endpoint} after {MAX_ATTEMPTS} attempts "
"(still queued or throttled) — wait a minute and re-run; "
"completed work is cached."
)
# -- typed endpoint wrappers ------------------------------------------
def search(
self, query: str, types: str = "boardgame,boardgameexpansion"
) -> list[SearchResult]:
return parse_search(self.get_xml("search", {"query": query, "type": types}))
def things(
self,
ids: Iterable[int],
stats: bool = False,
versions: bool = False,
) -> list[ThingDetails]:
params = {"id": ",".join(str(i) for i in ids)}
if stats:
params["stats"] = "1"
if versions:
params["versions"] = "1"
return parse_things(self.get_xml("thing", params))
def collection(
self,
username: str,
subtype: str | None = None,
version: bool = True,
) -> list[CollectionItem]:
params = {"username": username, "own": "1"}
if subtype:
params["subtype"] = subtype
if version:
params["version"] = "1"
return parse_collection(self.get_xml("collection", params))
def collection_full(self, username: str) -> list[CollectionItem]:
"""Owned items incl. expansions (excluded from the default subtype)."""
base = self.collection(username)
expansions = self.collection(username, subtype="boardgameexpansion")
seen = {item.coll_id for item in base}
return base + [e for e in expansions if e.coll_id not in seen]
+154
View File
@@ -0,0 +1,154 @@
"""Dataclasses + XML parsers for the BGG XML API2 responses we consume."""
from __future__ import annotations
import xml.etree.ElementTree as ET # element types only; parsing goes via defusedxml
from dataclasses import dataclass, field
from defusedxml.ElementTree import fromstring as _safe_fromstring
class BGGResponseError(Exception):
"""The API returned a well-formed error document (e.g. bad username)."""
@dataclass(frozen=True)
class SearchResult:
bgg_id: int
name: str
name_type: str # "primary" | "alternate"
year: int | None
type: str # "boardgame" | "boardgameexpansion"
@dataclass(frozen=True)
class GameVersion:
version_id: int
name: str
year: int | None
publishers: tuple[str, ...]
languages: tuple[str, ...]
@dataclass(frozen=True)
class ThingDetails:
bgg_id: int
name: str
year: int | None
type: str
owned: int | None = None
rank: int | None = None
versions: tuple[GameVersion, ...] = field(default=())
@dataclass(frozen=True)
class CollectionItem:
object_id: int
coll_id: int
name: str
subtype: str
own: bool
year: int | None
version_id: int | None
def _root(xml_text: str) -> ET.Element:
root = _safe_fromstring(xml_text)
if root.tag == "errors":
message = root.findtext("./error/message") or "unknown BGG error"
raise BGGResponseError(message.strip())
return root
def _attr_int(elem: ET.Element | None, attr: str = "value") -> int | None:
if elem is None:
return None
raw = elem.get(attr)
if raw is None or not raw.lstrip("-").isdigit():
return None
return int(raw)
def parse_search(xml_text: str) -> list[SearchResult]:
results = []
for item in _root(xml_text).findall("item"):
name_elem = item.find("name")
if name_elem is None or item.get("id") is None:
continue
results.append(
SearchResult(
bgg_id=int(item.get("id")),
name=name_elem.get("value", ""),
name_type=name_elem.get("type", "primary"),
year=_attr_int(item.find("yearpublished")),
type=item.get("type", "boardgame"),
)
)
return results
def _parse_version(item: ET.Element) -> GameVersion | None:
version_id = item.get("id")
name = item.find("name[@type='primary']")
if version_id is None:
return None
return GameVersion(
version_id=int(version_id),
name=name.get("value", "") if name is not None else "",
year=_attr_int(item.find("yearpublished")),
publishers=tuple(
link.get("value", "")
for link in item.findall("link[@type='boardgamepublisher']")
),
languages=tuple(
link.get("value", "") for link in item.findall("link[@type='language']")
),
)
def parse_things(xml_text: str) -> list[ThingDetails]:
things = []
for item in _root(xml_text).findall("item"):
name = item.find("name[@type='primary']")
rank_elem = item.find(".//ranks/rank[@name='boardgame']")
versions = [
v
for v_item in item.findall("versions/item")
if (v := _parse_version(v_item)) is not None
]
things.append(
ThingDetails(
bgg_id=int(item.get("id", 0)),
name=name.get("value", "") if name is not None else "",
year=_attr_int(item.find("yearpublished")),
type=item.get("type", "boardgame"),
owned=_attr_int(item.find(".//ratings/owned")),
rank=_attr_int(rank_elem),
versions=tuple(versions),
)
)
return things
def parse_collection(xml_text: str) -> list[CollectionItem]:
items = []
for item in _root(xml_text).findall("item"):
status = item.find("status")
year_text = item.findtext("yearpublished")
version_item = item.find("version/item")
items.append(
CollectionItem(
object_id=int(item.get("objectid", 0)),
coll_id=int(item.get("collid", 0)),
name=item.findtext("name", default=""),
subtype=item.get("subtype", "boardgame"),
own=status is not None and status.get("own") == "1",
year=int(year_text) if year_text and year_text.isdigit() else None,
version_id=(
int(version_item.get("id"))
if version_item is not None and version_item.get("id")
else None
),
)
)
return items