Skip to content

Commit 30a04e0

Browse files
authored
Merge pull request #4125 from sdornan/claude/hltb-api-503-error-806ce9
fix(hltb): renew rejected HLTB sessions instead of failing the scan
2 parents a5854b5 + e8993c1 commit 30a04e0

3 files changed

Lines changed: 502 additions & 73 deletions

File tree

backend/endpoints/sockets/scan.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -885,7 +885,7 @@ async def scan_platforms(
885885

886886
# Initialize HLTB handler (fetches current search endpoint and security token)
887887
if MetadataSource.HLTB in metadata_sources:
888-
meta_hltb_handler.initialize()
888+
await meta_hltb_handler.initialize()
889889

890890
# Resolve the platforms that will actually be scanned. When no platform ids
891891
# are provided, every filesystem platform is scanned.

backend/handler/metadata/hltb_handler.py

Lines changed: 170 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import json
23
import re
34
import time
@@ -10,14 +11,28 @@
1011
from handler.metadata.base_handler import UniversalPlatformSlug as UPS
1112
from logger.logger import log
1213
from utils import get_version
13-
from utils.context import create_httpx_client, ctx_httpx_client
14+
from utils.context import ctx_httpx_client
15+
from utils.rate_limiter import RateLimiter
1416

1517
from .base_handler import BaseRom, MetadataHandler
1618

1719
# Regex to detect HLTB ID tags in filenames like (hltb-12345)
1820
HLTB_TAG_REGEX = re.compile(r"\(hltb-(\d+)\)", re.IGNORECASE)
1921
DASH_COLON_REGEX = re.compile(r"\s?-\s")
2022

23+
# HLTB publishes no rate limit, so stay well clear of being throttled.
24+
HLTB_MAX_REQUESTS_PER_SECOND: Final[float] = 3
25+
# One attempt, plus one for a renewed session and one for a rate-limit backoff.
26+
HLTB_MAX_REQUEST_ATTEMPTS: Final[int] = 3
27+
HLTB_RATE_LIMIT_BACKOFF_SECONDS: Final[float] = 2
28+
_rate_limiter = RateLimiter(HLTB_MAX_REQUESTS_PER_SECOND)
29+
30+
# The session token decodes to "<issued-at>::<public IP>|<user agent>|<key>|<hmac>",
31+
# so logging it would put the host's public IP in any shared log or support bundle.
32+
HLTB_SESSION_HEADERS: Final[frozenset[str]] = frozenset(
33+
{"x-auth-token", "x-hp-key", "x-hp-val"}
34+
)
35+
2136

2237
class HLTBPlatform(TypedDict):
2338
slug: str
@@ -169,70 +184,101 @@ def extract_hltb_metadata(game: HLTBGame) -> HLTBMetadata:
169184
GITHUB_FILE_URL = "https://raw.githubusercontent.com/rommapp/romm/refs/heads/master/backend/handler/metadata/fixtures/hltb_api_url"
170185

171186

187+
def _unavailable_detail(status_code: int) -> str:
188+
"""Describe why HLTB is unusable, so the cause isn't misreported as a network fault."""
189+
if status_code == status.HTTP_403_FORBIDDEN:
190+
return (
191+
"HowLongToBeat rejected the session, it may have expired or this "
192+
"server's public IP address may have changed"
193+
)
194+
if status_code == status.HTTP_429_TOO_MANY_REQUESTS:
195+
return "HowLongToBeat is rate limiting requests, try again later"
196+
if status_code == status.HTTP_404_NOT_FOUND:
197+
return (
198+
"HowLongToBeat search endpoint not found, it has likely rotated and "
199+
"RomM could not fetch the current one"
200+
)
201+
return f"HowLongToBeat API returned HTTP {status_code}"
202+
203+
172204
class HLTBHandler(MetadataHandler):
173205
"""
174206
Handler for HowLongToBeat, a service that provides game completion times.
175207
"""
176208

177209
def __init__(self) -> None:
178-
self.base_url = "https://howlongtobeat.com"
179-
self.user_endpoint = f"{self.base_url}/api/user"
180-
self.stats_endpoint = f"{self.base_url}/api/stats/games?platform=1&year=2000"
181-
self.search_url = f"{self.base_url}/api/find"
182-
self.search_init_url = f"{self.search_url}/init"
183-
self.security_token = None
184-
self.hp_key = None
185-
self.hp_val = None
186-
self.min_similarity_score: Final = 0.85
210+
self.base_url: str = "https://howlongtobeat.com"
211+
self.user_endpoint: str = f"{self.base_url}/api/user"
212+
self.stats_endpoint: str = (
213+
f"{self.base_url}/api/stats/games?platform=1&year=2000"
214+
)
215+
self.search_url: str = f"{self.base_url}/api/find"
216+
self.search_init_url: str = f"{self.search_url}/init"
217+
self.security_token: str | None = None
218+
self.hp_key: str | None = None
219+
self.hp_val: str | None = None
220+
self.min_similarity_score: Final[float] = 0.85
187221

188222
@classmethod
189223
def is_enabled(cls) -> bool:
190224
return HLTB_API_ENABLED
191225

192-
def initialize(self) -> None:
226+
async def initialize(self) -> None:
193227
# HLTB rotates their search endpoint regularly
194-
self._fetch_search_endpoint()
228+
await self._fetch_search_endpoint()
195229

196230
# HLTB now requires a security token
197-
self._fetch_security_token()
231+
await self._fetch_security_token()
232+
233+
def _base_headers(self) -> dict[str, str]:
234+
# HLTB binds a session to the user agent that requested it, so every call
235+
# has to send the same one.
236+
return {
237+
"Referer": self.base_url,
238+
"User-Agent": f"RomM/{get_version()}",
239+
}
240+
241+
def _has_session(self) -> bool:
242+
return bool(self.security_token and self.hp_key and self.hp_val)
198243

199-
def _fetch_search_endpoint(self):
244+
async def _fetch_search_endpoint(self) -> None:
200245
"""Fetch the API endpoint URL from Github."""
201246
if not HLTB_API_ENABLED:
202247
return
203248

249+
httpx_client = ctx_httpx_client.get()
250+
204251
try:
205-
with create_httpx_client() as client:
206-
response = client.get(GITHUB_FILE_URL, timeout=10)
207-
response.raise_for_status()
208-
self.search_url = response.text.strip()
209-
self.search_init_url = f"{self.search_url}/init"
252+
response = await httpx_client.get(GITHUB_FILE_URL, timeout=10)
253+
response.raise_for_status()
254+
self.search_url = response.text.strip()
255+
self.search_init_url = f"{self.search_url}/init"
210256
except Exception as e:
211257
log.warning("Unexpected error fetching HLTB endpoint from GitHub: %s", e)
212258

213-
def _fetch_security_token(self):
259+
async def _fetch_security_token(self) -> None:
214260
if not HLTB_API_ENABLED:
215261
return
216262

217-
headers = {
218-
"Referer": "https://howlongtobeat.com",
219-
"User-Agent": f"RomM/{get_version()}",
220-
}
263+
httpx_client = ctx_httpx_client.get()
221264
params = {"t": int(time.time())}
222265

266+
# /init is HLTB traffic too, and a wave of concurrent renewals would
267+
# otherwise burst past the cap the search requests respect.
268+
await _rate_limiter.acquire()
269+
223270
try:
224-
with create_httpx_client() as client:
225-
response = client.get(
226-
self.search_init_url,
227-
params=params,
228-
headers=headers,
229-
timeout=10,
230-
)
231-
response.raise_for_status()
232-
data = response.json()
233-
self.security_token = data.get("token", None)
234-
self.hp_key = data.get("hpKey", None)
235-
self.hp_val = data.get("hpVal", None)
271+
response = await httpx_client.get(
272+
self.search_init_url,
273+
params=params,
274+
headers=self._base_headers(),
275+
timeout=10,
276+
)
277+
response.raise_for_status()
278+
data = response.json()
279+
self.security_token = data.get("token", None)
280+
self.hp_key = data.get("hpKey", None)
281+
self.hp_val = data.get("hpVal", None)
236282
except Exception as e:
237283
log.warning("Unexpected error fetching HLTB security token: %s", e)
238284

@@ -242,7 +288,9 @@ async def heartbeat(self) -> bool:
242288

243289
httpx_client = ctx_httpx_client.get()
244290
try:
245-
response = await httpx_client.get(self.stats_endpoint)
291+
response = await httpx_client.get(
292+
self.stats_endpoint, headers=self._base_headers()
293+
)
246294
response.raise_for_status()
247295
except Exception as e:
248296
log.error("Error checking HLTB API: %s", e)
@@ -254,53 +302,103 @@ async def _request(self, url: str, payload: dict) -> dict:
254302
"""
255303
Sends a POST request to HowLongToBeat API.
256304
305+
HLTB sessions are short-lived and pinned to the requesting IP address and
306+
user agent, so a rejected session is renewed and the call retried rather
307+
than failing every remaining lookup in a scan.
308+
257309
:param url: The API endpoint URL.
258310
:param payload: A dictionary containing the request payload.
259311
:return: A dictionary with the json result.
260312
:raises HTTPException: If the request fails or the service is unavailable.
261313
"""
262-
if not self.security_token or not self.hp_key or not self.hp_val:
314+
if not self._has_session():
263315
return {}
264316

265317
httpx_client = ctx_httpx_client.get()
266318

267-
headers = {
268-
"Content-Type": "application/json",
269-
"Referer": "https://howlongtobeat.com",
270-
"User-Agent": f"RomM/{get_version()}",
271-
"x-auth-token": self.security_token,
272-
"x-hp-key": self.hp_key,
273-
"x-hp-val": self.hp_val,
274-
}
275-
276-
# Some HLTB endpoints require the key:val in the payload
277-
payload[self.hp_key] = self.hp_val
319+
for attempt in range(HLTB_MAX_REQUEST_ATTEMPTS):
320+
await _rate_limiter.acquire()
321+
322+
# Read the session only after waiting, never before: this handler is a
323+
# shared singleton, so a peer may have renewed (or lost) the session
324+
# while we were paced.
325+
if not self._has_session():
326+
return {}
327+
328+
headers = {
329+
"Content-Type": "application/json",
330+
**self._base_headers(),
331+
"x-auth-token": self.security_token or "",
332+
"x-hp-key": self.hp_key or "",
333+
"x-hp-val": self.hp_val or "",
334+
}
278335

279-
log.debug(
280-
"HowLongToBeat API request: URL=%s, Headers=%s, Payload=%s, Timeout=%s",
281-
url,
282-
headers,
283-
payload,
284-
60,
285-
)
336+
# Some HLTB endpoints require the key:val in the payload. The key rotates
337+
# with the session, so copy the payload instead of accumulating stale keys.
338+
body = {**payload, self.hp_key or "": self.hp_val}
286339

287-
try:
288-
res = await httpx_client.post(
289-
url, json=payload, headers=headers, timeout=60
290-
)
291-
res.raise_for_status()
292-
return res.json()
293-
except (httpx.HTTPStatusError, httpx.ConnectError, httpx.ReadTimeout) as exc:
294-
log.warning(
295-
"Connection error: can't connect to HowLongToBeat API", exc_info=True
340+
log.debug(
341+
"HowLongToBeat API request: URL=%s, Headers=%s, Payload=%s, Timeout=%s",
342+
url,
343+
{
344+
key: "[redacted]" if key in HLTB_SESSION_HEADERS else value
345+
for key, value in headers.items()
346+
},
347+
{key: value for key, value in body.items() if key != self.hp_key},
348+
60,
296349
)
297-
raise HTTPException(
298-
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
299-
detail="Can't connect to HowLongToBeat API, check your internet connection",
300-
) from exc
301-
except json.JSONDecodeError as exc:
302-
log.error("Error decoding JSON response from HowLongToBeat API: %s", exc)
303-
return {}
350+
351+
try:
352+
res = await httpx_client.post(
353+
url, json=body, headers=headers, timeout=60
354+
)
355+
res.raise_for_status()
356+
return res.json()
357+
except httpx.HTTPStatusError as exc:
358+
status_code = exc.response.status_code
359+
is_last_attempt = attempt == HLTB_MAX_REQUEST_ATTEMPTS - 1
360+
361+
if status_code == status.HTTP_403_FORBIDDEN and not is_last_attempt:
362+
log.warning("HowLongToBeat rejected the session, renewing it")
363+
await self._fetch_security_token()
364+
if not self._has_session():
365+
return {}
366+
continue
367+
368+
if (
369+
status_code == status.HTTP_429_TOO_MANY_REQUESTS
370+
and not is_last_attempt
371+
):
372+
log.warning(
373+
"HowLongToBeat rate limit hit, retrying after %ss",
374+
HLTB_RATE_LIMIT_BACKOFF_SECONDS,
375+
)
376+
await asyncio.sleep(HLTB_RATE_LIMIT_BACKOFF_SECONDS)
377+
continue
378+
379+
log.warning(
380+
"HowLongToBeat API returned HTTP %s", status_code, exc_info=True
381+
)
382+
raise HTTPException(
383+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
384+
detail=_unavailable_detail(status_code),
385+
) from exc
386+
except (httpx.ConnectError, httpx.ReadTimeout) as exc:
387+
log.warning(
388+
"Connection error: can't connect to HowLongToBeat API",
389+
exc_info=True,
390+
)
391+
raise HTTPException(
392+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
393+
detail="Can't connect to HowLongToBeat API, check your internet connection",
394+
) from exc
395+
except json.JSONDecodeError as exc:
396+
log.error(
397+
"Error decoding JSON response from HowLongToBeat API: %s", exc
398+
)
399+
return {}
400+
401+
return {}
304402

305403
async def search_games(
306404
self, search_term: str, platform_slug: str

0 commit comments

Comments
 (0)