TiGa-RCE commited on
Commit
54a5f89
·
verified ·
1 Parent(s): a6e1920

Handle Steam rate limits with bounded cooldown and safe status

Browse files

Keep existing Space access settings and simulator binaries. Fix required Id, Stats and Snapshots in exported game networks. Add optional read-only Steam comparison, run-wide best lap and bounded request admission. Formula remains the only hosted vehicle.

Files changed (3) hide show
  1. RELEASE_MANIFEST.json +2 -2
  2. server.py +8 -2
  3. steam_leaderboard.py +54 -9
RELEASE_MANIFEST.json CHANGED
@@ -60,8 +60,8 @@
60
  "hf_identity.py": "6993d0238ada835606dbdc3275c17a3fe5a5cac5ad2156ab78230acf2d051eba",
61
  "index.html": "713496757a5fa796cf35eb9fba7a4f29b0eb2f8abdd6c8e3a6fdfd8036a3a30f",
62
  "requirements.txt": "9b532747e51bd052e82090ece860d6dafe027407f4512dc74272c148ea104c22",
63
- "server.py": "af567a30347bbcb75dda324408c47bc2fa8b519693d95c450403fd3012eb8fc5",
64
- "steam_leaderboard.py": "0f2dbe9615f1bcda2e8f4399a2fa4257ef86a751887dd0b41e0f65034ed3138b",
65
  "track-library.js": "351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6",
66
  "track-map.js": "ab5374aa49c7ccec90a787ee8c56cd71beb56b903bf2bbc322783e6f43c009f0",
67
  "training_config.py": "a0dd095e0de8c5a964ace66954f346e5d7b861db3d34cd2feaceedc5ecb80bad",
 
60
  "hf_identity.py": "6993d0238ada835606dbdc3275c17a3fe5a5cac5ad2156ab78230acf2d051eba",
61
  "index.html": "713496757a5fa796cf35eb9fba7a4f29b0eb2f8abdd6c8e3a6fdfd8036a3a30f",
62
  "requirements.txt": "9b532747e51bd052e82090ece860d6dafe027407f4512dc74272c148ea104c22",
63
+ "server.py": "679a5f36997a2c3c2808e71402588789b8fd3e246b4da4ed0a5f6f5621d138c3",
64
+ "steam_leaderboard.py": "d76397c39cae58f636ddf9a9be1a46ae3d77fefd6b7269feac73d34d97165481",
65
  "track-library.js": "351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6",
66
  "track-map.js": "ab5374aa49c7ccec90a787ee8c56cd71beb56b903bf2bbc322783e6f43c009f0",
67
  "training_config.py": "a0dd095e0de8c5a964ace66954f346e5d7b861db3d34cd2feaceedc5ecb80bad",
server.py CHANGED
@@ -50,8 +50,14 @@ def create_app(worker=run_reported,store=None,gpu_worker=None,track_library=None
50
  return {'status':'busy','message':'Another Steam check is in progress. The completed result is unaffected.'}
51
  try:
52
  return await run_in_threadpool(compare_public_best_lap,entry,body['lap_seconds'])
53
- except SteamUnavailable:
54
- return {'status':'unavailable','message':'Steam could not provide a complete public leaderboard. The completed result is unaffected.'}
 
 
 
 
 
 
55
  finally:
56
  leaderboard_admission.release()
57
  @app.get('/api/tracks/{identifier}/file')
 
50
  return {'status':'busy','message':'Another Steam check is in progress. The completed result is unaffected.'}
51
  try:
52
  return await run_in_threadpool(compare_public_best_lap,entry,body['lap_seconds'])
53
+ except SteamUnavailable as error:
54
+ result={'status':'unavailable','failure_category':error.failure_category,
55
+ 'message':'Steam could not provide a complete public leaderboard. The completed result is unaffected.'}
56
+ if error.failure_category == 'rate_limited':
57
+ result['message']='Steam is limiting leaderboard requests. Checks pause for one minute; your completed result is unaffected.'
58
+ if error.upstream_http_status is not None:
59
+ result['upstream_http_status']=error.upstream_http_status
60
+ return result
61
  finally:
62
  leaderboard_admission.release()
63
  @app.get('/api/tracks/{identifier}/file')
steam_leaderboard.py CHANGED
@@ -4,8 +4,10 @@ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
4
  from html.parser import HTMLParser
5
  import hashlib
6
  import re
 
7
  import threading
8
  import time
 
9
  from urllib.parse import urlparse
10
  from urllib.request import Request, urlopen
11
  import xml.etree.ElementTree as ET
@@ -17,15 +19,28 @@ TIMEOUT_SECONDS = 4
17
  TOTAL_TIMEOUT_SECONDS = 10
18
  INDEX_TTL_SECONDS = 300
19
  BOARD_TTL_SECONDS = 60
 
 
 
 
20
 
21
  _cache_lock = threading.Lock()
 
22
  _index_cache = None
23
  _board_cache = {}
 
24
 
25
 
26
  class SteamUnavailable(Exception):
27
  """The public Community endpoint could not provide a validated board."""
28
 
 
 
 
 
 
 
 
29
 
30
  class BoardOptions(HTMLParser):
31
  def __init__(self):
@@ -55,29 +70,57 @@ class BoardOptions(HTMLParser):
55
  def _read(url):
56
  # Callers pass only INDEX_URL or a URL assembled from a numeric ID in that
57
  # verified public index. No request data is placed in a Steam URL.
 
 
 
 
 
 
 
 
 
 
58
  request = Request(url, headers={'User-Agent': 'ALD-hosted-public-board-check/1.0'})
59
  started = time.monotonic()
60
  try:
61
  with urlopen(request, timeout=TIMEOUT_SECONDS) as response:
62
  final = urlparse(response.geturl())
63
  if final.scheme != 'https' or final.hostname != 'steamcommunity.com':
64
- raise SteamUnavailable('Steam public leaderboard redirected outside its fixed host')
65
  chunks, size = [], 0
66
  while True:
67
  if time.monotonic() - started >= TOTAL_TIMEOUT_SECONDS:
68
- raise SteamUnavailable('Steam public leaderboard request exceeded its time limit')
69
  chunk = response.read(min(65536, MAX_BYTES + 1 - size))
70
  if not chunk:
71
  break
72
  chunks.append(chunk)
73
  size += len(chunk)
74
  if size > MAX_BYTES:
75
- raise SteamUnavailable('Steam public leaderboard response exceeded the size limit')
76
  return b''.join(chunks)
77
  except SteamUnavailable:
78
  raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  except Exception as error:
80
- raise SteamUnavailable('Steam public leaderboard is temporarily unavailable') from error
81
 
82
 
83
  def _index():
@@ -91,14 +134,14 @@ def _index():
91
  try:
92
  parser.feed(raw.decode('utf-8'))
93
  except (UnicodeDecodeError, ValueError) as error:
94
- raise SteamUnavailable('Steam public leaderboard index was invalid') from error
95
  if parser.duplicate:
96
- raise SteamUnavailable('Steam public leaderboard index had duplicate IDs')
97
  boards = {}
98
  for identifier, label in parser.options.items():
99
  if identifier.isdecimal() and label:
100
  if label in boards:
101
- raise SteamUnavailable('Steam public leaderboard index had ambiguous track labels')
102
  boards[label] = (int(identifier), label)
103
  fetched = datetime.now(timezone.utc).isoformat()
104
  _index_cache = (now, {'boards': boards, 'sha256': hashlib.sha256(raw).hexdigest(), 'fetched_at_utc': fetched})
@@ -126,7 +169,7 @@ def _parse_board(raw, board_id):
126
  if count < 1 or received != count or len(entries) != count:
127
  raise ValueError()
128
  except (ET.ParseError, TypeError, ValueError, OverflowError) as error:
129
- raise SteamUnavailable('Steam returned an incomplete or mismatched leaderboard') from error
130
  return entries
131
 
132
 
@@ -222,7 +265,9 @@ def compare_public_best_lap(track, lap_seconds):
222
 
223
 
224
  def _reset_cache_for_tests():
225
- global _index_cache
226
  with _cache_lock:
227
  _index_cache = None
228
  _board_cache.clear()
 
 
 
4
  from html.parser import HTMLParser
5
  import hashlib
6
  import re
7
+ import socket
8
  import threading
9
  import time
10
+ from urllib.error import HTTPError, URLError
11
  from urllib.parse import urlparse
12
  from urllib.request import Request, urlopen
13
  import xml.etree.ElementTree as ET
 
19
  TOTAL_TIMEOUT_SECONDS = 10
20
  INDEX_TTL_SECONDS = 300
21
  BOARD_TTL_SECONDS = 60
22
+ RATE_LIMIT_COOLDOWN_SECONDS = 60
23
+ FAILURE_CATEGORIES = frozenset({
24
+ 'rate_limited', 'upstream_http_error', 'timeout', 'network_error', 'invalid_response',
25
+ })
26
 
27
  _cache_lock = threading.Lock()
28
+ _rate_limit_lock = threading.Lock()
29
  _index_cache = None
30
  _board_cache = {}
31
+ _rate_limited_until = 0.0
32
 
33
 
34
  class SteamUnavailable(Exception):
35
  """The public Community endpoint could not provide a validated board."""
36
 
37
+ def __init__(self, message, *, failure_category='invalid_response', upstream_http_status=None):
38
+ if failure_category not in FAILURE_CATEGORIES:
39
+ raise ValueError('Unknown Steam failure category')
40
+ self.failure_category = failure_category
41
+ self.upstream_http_status = upstream_http_status
42
+ super().__init__(message)
43
+
44
 
45
  class BoardOptions(HTMLParser):
46
  def __init__(self):
 
70
  def _read(url):
71
  # Callers pass only INDEX_URL or a URL assembled from a numeric ID in that
72
  # verified public index. No request data is placed in a Steam URL.
73
+ global _rate_limited_until
74
+ now = time.monotonic()
75
+ with _rate_limit_lock:
76
+ if now < _rate_limited_until:
77
+ raise SteamUnavailable(
78
+ 'Steam public leaderboard is cooling down after rate limiting',
79
+ failure_category='rate_limited', upstream_http_status=429,
80
+ )
81
+ if _rate_limited_until:
82
+ _rate_limited_until = 0.0
83
  request = Request(url, headers={'User-Agent': 'ALD-hosted-public-board-check/1.0'})
84
  started = time.monotonic()
85
  try:
86
  with urlopen(request, timeout=TIMEOUT_SECONDS) as response:
87
  final = urlparse(response.geturl())
88
  if final.scheme != 'https' or final.hostname != 'steamcommunity.com':
89
+ raise SteamUnavailable('Steam public leaderboard redirected outside its fixed host', failure_category='invalid_response')
90
  chunks, size = [], 0
91
  while True:
92
  if time.monotonic() - started >= TOTAL_TIMEOUT_SECONDS:
93
+ raise SteamUnavailable('Steam public leaderboard request exceeded its time limit', failure_category='timeout')
94
  chunk = response.read(min(65536, MAX_BYTES + 1 - size))
95
  if not chunk:
96
  break
97
  chunks.append(chunk)
98
  size += len(chunk)
99
  if size > MAX_BYTES:
100
+ raise SteamUnavailable('Steam public leaderboard response exceeded the size limit', failure_category='invalid_response')
101
  return b''.join(chunks)
102
  except SteamUnavailable:
103
  raise
104
+ except HTTPError as error:
105
+ if error.code == 429:
106
+ with _rate_limit_lock:
107
+ _rate_limited_until = max(_rate_limited_until, time.monotonic() + RATE_LIMIT_COOLDOWN_SECONDS)
108
+ raise SteamUnavailable(
109
+ 'Steam public leaderboard is rate limited',
110
+ failure_category='rate_limited', upstream_http_status=429,
111
+ ) from error
112
+ raise SteamUnavailable(
113
+ 'Steam public leaderboard returned an HTTP error',
114
+ failure_category='upstream_http_error', upstream_http_status=error.code,
115
+ ) from error
116
+ except (socket.timeout, TimeoutError) as error:
117
+ raise SteamUnavailable('Steam public leaderboard request timed out', failure_category='timeout') from error
118
+ except URLError as error:
119
+ if isinstance(error.reason, (socket.timeout, TimeoutError)):
120
+ raise SteamUnavailable('Steam public leaderboard request timed out', failure_category='timeout') from error
121
+ raise SteamUnavailable('Steam public leaderboard network request failed', failure_category='network_error') from error
122
  except Exception as error:
123
+ raise SteamUnavailable('Steam public leaderboard network request failed', failure_category='network_error') from error
124
 
125
 
126
  def _index():
 
134
  try:
135
  parser.feed(raw.decode('utf-8'))
136
  except (UnicodeDecodeError, ValueError) as error:
137
+ raise SteamUnavailable('Steam public leaderboard index was invalid', failure_category='invalid_response') from error
138
  if parser.duplicate:
139
+ raise SteamUnavailable('Steam public leaderboard index had duplicate IDs', failure_category='invalid_response')
140
  boards = {}
141
  for identifier, label in parser.options.items():
142
  if identifier.isdecimal() and label:
143
  if label in boards:
144
+ raise SteamUnavailable('Steam public leaderboard index had ambiguous track labels', failure_category='invalid_response')
145
  boards[label] = (int(identifier), label)
146
  fetched = datetime.now(timezone.utc).isoformat()
147
  _index_cache = (now, {'boards': boards, 'sha256': hashlib.sha256(raw).hexdigest(), 'fetched_at_utc': fetched})
 
169
  if count < 1 or received != count or len(entries) != count:
170
  raise ValueError()
171
  except (ET.ParseError, TypeError, ValueError, OverflowError) as error:
172
+ raise SteamUnavailable('Steam returned an incomplete or mismatched leaderboard', failure_category='invalid_response') from error
173
  return entries
174
 
175
 
 
265
 
266
 
267
  def _reset_cache_for_tests():
268
+ global _index_cache, _rate_limited_until
269
  with _cache_lock:
270
  _index_cache = None
271
  _board_cache.clear()
272
+ with _rate_limit_lock:
273
+ _rate_limited_until = 0.0