-
-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathapi_helper.py
More file actions
419 lines (352 loc) · 16.6 KB
/
Copy pathapi_helper.py
File metadata and controls
419 lines (352 loc) · 16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
"""
API Helper
HTTP requests, response caching and ESPN fetch helpers for plugins
(``from src.common import APIHelper``), plus the headers every core request
sends (:data:`USER_AGENT`, :data:`DEFAULT_HTTP_HEADERS`).
"""
import logging
import time
from datetime import datetime
from types import MappingProxyType
from src.common.espn_dates import (
ESPN_MAX_LIMIT,
espn_scoreboard_cache_key,
read_espn_scoreboard_cache,
store_espn_scoreboard_cache,
)
from src.common.fetch_service import fetch_get, fetch_post, share_connection_pool
from src.common.json_body import response_json
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast
import requests
from urllib3.util.retry import Retry
if TYPE_CHECKING:
# What Session() puts in .headers; the stubs only promise a MutableMapping.
from requests.structures import CaseInsensitiveDict
#: The User-Agent core sends to ESPN and other data APIs. It names the client
#: and links to it: around 2026-08-04 ESPN began 403ing bare custom tokens
#: (and browser strings), and this form is what it accepts.
USER_AGENT = 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'
#: Base headers for core's JSON API requests. Read-only; pass
#: ``{**DEFAULT_HTTP_HEADERS, ...}`` to add to it. There is deliberately no
#: Accept-Encoding: requests advertises only what urllib3 can decode here
#: (``br`` needs the optional brotli package, which is not a requirement), so a
#: hand-set ``br`` invites a body the client cannot read.
DEFAULT_HTTP_HEADERS: Mapping[str, str] = MappingProxyType({
'User-Agent': USER_AGENT,
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
})
class APIHelper:
"""
HTTP requests with retries, response caching and ESPN helpers.
- Requests go through one ``requests.Session`` that retries GET, HEAD
and OPTIONS on 429 and 5xx with exponential backoff, and sends
:data:`DEFAULT_HTTP_HEADERS`. Its connection pool is shared with every
other helper using the same retry policy, and requests go through the
core fetch service (``src/common/fetch_service.py``): identical GETs in
flight are merged, hosts with a budget are paced, and requests are
counted per plugin. Return values and errors are unchanged.
- Consecutive requests from one helper are spaced at least
``set_rate_limit()`` seconds apart (1 second by default). A cache hit
does not count.
- With a ``cache_manager``, :meth:`get` caches the parsed JSON under
``cache_key`` for ``cache_ttl`` seconds. The lifetime is stored with
the entry, so CacheManager honours it on every later read, whatever
max_age that read asks for.
- Failed requests are logged and return None; nothing here raises for a
network or HTTP error.
"""
def __init__(self, cache_manager=None, default_timeout: int = 30,
max_retries: int = 3, logger: Optional[logging.Logger] = None):
"""
Initialize the APIHelper.
Args:
cache_manager: Optional cache manager for response caching
default_timeout: Default timeout for requests in seconds
max_retries: Maximum number of retry attempts
logger: Optional logger instance
"""
self.cache_manager = cache_manager
self.default_timeout = default_timeout
self.max_retries = max_retries
self.logger = logger or logging.getLogger(__name__)
# Setup session with retry strategy
self.session = requests.Session()
retry_strategy = Retry(
total=max_retries,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "HEAD", "OPTIONS"]
)
# The shared adapter for this retry policy: the same retries as a
# private HTTPAdapter(max_retries=retry_strategy), with the connection
# pool shared by every helper (fetch_service).
share_connection_pool(self.session, retry_strategy)
self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
# Rate limiting
self._last_request_time: float = 0 # wall clock, reported by get_request_stats()
# The interval is measured on time.monotonic(): a wall-clock step
# back (NTP correcting a Pi with no RTC) made time_since_last
# negative and the "remaining interval" sleep as long as the step.
self._last_request_monotonic: Optional[float] = None
self._min_request_interval = 1.0 # Minimum seconds between requests
def get(self, url: str, params: Optional[Dict] = None,
headers: Optional[Dict] = None, timeout: Optional[int] = None,
cache_key: Optional[str] = None, cache_ttl: int = 3600) -> Optional[Dict]:
"""
Make a GET request with optional caching.
Args:
url: URL to request
params: Query parameters
headers: Additional headers
timeout: Request timeout (uses default if None)
cache_key: Key for caching response
cache_ttl: Cache time-to-live in seconds
Returns:
Response data as dictionary or None if request fails
"""
return self._get(url, params, headers, timeout, cache_key, cache_ttl,
cache_ttl if cache_key else None)
def _get(self, url: str, params: Optional[Dict], headers: Optional[Dict],
timeout: Optional[int], cache_key: Optional[str], cache_ttl: int,
cache_max_age: Optional[float]) -> Optional[Dict]:
""":meth:`get`, saying how old a response the fetch service's short
response cache may hand back (``cache_max_age``, the caller's TTL)."""
if cache_key and self.cache_manager:
cached = self._get_from_cache(cache_key, cache_ttl)
if cached is not None:
self.logger.debug(f"Using cached response for {cache_key}")
return cast(Dict[Any, Any], cached)
# Rate limiting
self._enforce_rate_limit()
try:
# Prepare request
request_headers = cast('CaseInsensitiveDict[Any]', self.session.headers).copy()
if headers:
request_headers.update(headers)
# Make request
response = fetch_get(
self.session,
url,
params=params,
headers=request_headers,
timeout=timeout or self.default_timeout,
cache_max_age=cache_max_age,
)
response.raise_for_status()
# Parse JSON response
data: Dict[Any, Any] = response_json(response)
# Cache response if cache key provided
if cache_key and self.cache_manager:
self._set_cache(cache_key, data, cache_ttl)
self.logger.debug(f"Successfully fetched {url}")
return data
except requests.exceptions.RequestException as e:
self.logger.error(f"Request failed for {url}: {e}")
return None
def fetch_espn_scoreboard(self, sport: str, league: str,
date: Optional[str] = None,
cache_key: Optional[str] = None,
cache_ttl: int = 300) -> Optional[Dict]:
"""
Fetch ESPN scoreboard data for a specific sport and league.
Args:
sport: Sport name (e.g., 'basketball', 'football')
league: League name (e.g., 'nba', 'nfl')
date: Date in YYYYMMDD format (defaults to today)
cache_key: Cache key for response. By default the canonical
``espn_scoreboard_cache_key(sport, league, date)``, shared
with every other consumer of this scoreboard, with the key
this used before (``espn_{sport}_{league}_{date}``) read as a
fallback for one release. An explicit key works as before.
cache_ttl: Cache time-to-live in seconds. A shared entry is
returned only while it is at most this old.
Returns:
ESPN API response data or None if request fails
"""
if date is None:
date = datetime.now().strftime('%Y%m%d')
# Build URL
url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard"
# Set parameters
# limit above 500 makes ESPN truncate instead of erroring: college
# football came back with 25 of 68 games. See src/common/espn_dates.py.
params = {
'dates': date,
'limit': ESPN_MAX_LIMIT
}
if cache_key is not None:
return self.get(url, params=params, cache_key=cache_key, cache_ttl=cache_ttl)
legacy_key = f"espn_{sport}_{league}_{date}"
try:
shared_key = espn_scoreboard_cache_key(sport, league, date)
except ValueError:
# Not a path or date the canonical key covers: the old key.
return self.get(url, params=params, cache_key=legacy_key, cache_ttl=cache_ttl)
if self.cache_manager:
cached = read_espn_scoreboard_cache(
self.cache_manager, shared_key, cache_ttl, legacy_keys=(legacy_key,))
if cached is not None:
self.logger.debug(f"Using cached response for {shared_key}")
return cast(Dict[Any, Any], cached)
data = self._get(url, params, None, None, None, cache_ttl, cache_ttl)
if data is not None and self.cache_manager:
store_espn_scoreboard_cache(self.cache_manager, shared_key, data)
return data
def fetch_espn_standings(self, sport: str, league: str,
cache_key: Optional[str] = None,
cache_ttl: int = 3600) -> Optional[Dict]:
"""
Fetch ESPN standings data for a specific sport and league.
Args:
sport: Sport name
league: League name
cache_key: Cache key for response
cache_ttl: Cache time-to-live in seconds
Returns:
ESPN standings data or None if request fails
"""
url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/standings"
if cache_key is None:
cache_key = f"espn_standings_{sport}_{league}"
return self.get(url, cache_key=cache_key, cache_ttl=cache_ttl)
def fetch_espn_rankings(self, sport: str, league: str,
cache_key: Optional[str] = None,
cache_ttl: int = 3600) -> Optional[Dict]:
"""
Fetch ESPN rankings data for a specific sport and league.
Args:
sport: Sport name
league: League name
cache_key: Cache key for response
cache_ttl: Cache time-to-live in seconds
Returns:
ESPN rankings data or None if request fails
"""
url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/rankings"
if cache_key is None:
cache_key = f"espn_rankings_{sport}_{league}"
return self.get(url, cache_key=cache_key, cache_ttl=cache_ttl)
def post(self, url: str, data: Optional[Dict] = None,
json_data: Optional[Dict] = None,
headers: Optional[Dict] = None,
timeout: Optional[int] = None) -> Optional[Dict]:
"""
Make a POST request.
Args:
url: URL to request
data: Form data
json_data: JSON data
headers: Additional headers
timeout: Request timeout
Returns:
Response data as dictionary or None if request fails
"""
self._enforce_rate_limit()
try:
request_headers = cast('CaseInsensitiveDict[Any]', self.session.headers).copy()
if headers:
request_headers.update(headers)
response = fetch_post(
self.session,
url,
data=data,
json=json_data,
headers=request_headers,
timeout=timeout or self.default_timeout
)
response.raise_for_status()
return cast(Optional[Dict[Any, Any]], response_json(response))
except requests.exceptions.RequestException as e:
self.logger.error(f"POST request failed for {url}: {e}")
return None
def set_cache(self, key: str, data: Any, ttl: int = 3600) -> None:
"""
Set cache data.
Args:
key: Cache key
data: Data to cache
ttl: Seconds the entry stays valid. Stored with the entry, so
it applies to every later read of ``key``.
"""
self._set_cache(key, data, ttl)
def get_cache(self, key: str) -> Optional[Any]:
"""
Get cached data.
Args:
key: Cache key
Returns:
Cached data, or None if there is none or it has expired. An
entry written with a ttl (set_cache, get) expires after that ttl;
one written without expires after CacheManager's default max_age.
"""
return self._get_from_cache(key)
def clear_cache(self, pattern: Optional[str] = None) -> None:
"""
Clear cache data.
Uses CacheManager's clear_cache(), or list_cache_files() and delete()
for a pattern. A cache manager without those methods is left alone.
Args:
pattern: Optional substring to match cache keys; only matching
entries are deleted.
"""
if not self.cache_manager:
return
if pattern:
if (hasattr(self.cache_manager, 'list_cache_files')
and hasattr(self.cache_manager, 'delete')):
for entry in self.cache_manager.list_cache_files():
key = entry.get('key') if isinstance(entry, dict) else None
if key and pattern in key:
self.cache_manager.delete(key)
else:
self.logger.debug(
"Cache manager lacks list_cache_files/delete; "
"cannot clear by pattern")
elif hasattr(self.cache_manager, 'clear_cache'):
self.cache_manager.clear_cache()
else:
self.logger.debug("Cache manager exposes no clear method; no-op")
def _get_from_cache(self, key: str, max_age: Optional[int] = None) -> Optional[Any]:
"""Cached data for ``key``, or None. ``max_age`` only matters for an
entry stored without a ttl; one stored with a ttl uses that."""
if not self.cache_manager:
return None
if max_age is None:
return self.cache_manager.get(key)
return self.cache_manager.get(key, max_age=max_age)
def _set_cache(self, key: str, data: Any, ttl: Optional[int]) -> None:
"""Store ``data`` under ``key`` for ``ttl`` seconds."""
if self.cache_manager:
self.cache_manager.set(key, data, ttl=ttl)
def _enforce_rate_limit(self) -> None:
"""Enforce rate limiting between requests."""
if self._last_request_monotonic is not None:
time_since_last = time.monotonic() - self._last_request_monotonic
if time_since_last < self._min_request_interval:
sleep_time = self._min_request_interval - time_since_last
time.sleep(sleep_time)
self._last_request_monotonic = time.monotonic()
self._last_request_time = time.time()
def set_rate_limit(self, min_interval: float) -> None:
"""
Set minimum interval between requests.
Args:
min_interval: Minimum seconds between requests
"""
self._min_request_interval = min_interval
self.logger.debug(f"Rate limit set to {min_interval} seconds")
def get_request_stats(self) -> Dict[str, Any]:
"""
Get request statistics.
Returns:
Dictionary with request statistics
"""
return {
'min_request_interval': self._min_request_interval,
'last_request_time': self._last_request_time,
'time_since_last_request': (
time.monotonic() - self._last_request_monotonic
if self._last_request_monotonic is not None
else time.time() - self._last_request_time),
}