Spaces:
Running on Zero
Running on Zero
Handle Steam rate limits with bounded cooldown and safe status
Browse filesKeep 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.
- RELEASE_MANIFEST.json +2 -2
- server.py +8 -2
- 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": "
|
| 64 |
-
"steam_leaderboard.py": "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|