1+ import asyncio
12import json
23import re
34import time
1011from handler .metadata .base_handler import UniversalPlatformSlug as UPS
1112from logger .logger import log
1213from 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
1517from .base_handler import BaseRom , MetadataHandler
1618
1719# Regex to detect HLTB ID tags in filenames like (hltb-12345)
1820HLTB_TAG_REGEX = re .compile (r"\(hltb-(\d+)\)" , re .IGNORECASE )
1921DASH_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
2237class HLTBPlatform (TypedDict ):
2338 slug : str
@@ -169,70 +184,101 @@ def extract_hltb_metadata(game: HLTBGame) -> HLTBMetadata:
169184GITHUB_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+
172204class 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