Files
LEDMatrix/src/common/fetch_service.py
T
ChuckandClaude Opus 5.5 4be53d048b feat(fetch): shared fetch service, stage 1 (pooling, merging, host budgets, counters) (#702)
Core's own HTTP fetch paths (APIHelper, fetch_espn_scoreboard and its date chunks, BackgroundDataService, BaseOddsManager.get_odds) go through one service in src/common/fetch_service.py: shared connection pools per retry policy, merged identical in-flight GETs, per-host token-bucket budgets (fetch_service.rate_limits), and per-plugin request counters published to GET /api/v3/plugins/fetch-stats. Return values, exceptions, cache keys, TTLs and retry policies are unchanged. Core-internal in this release; plugins should not import it directly yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:38:11 -04:00

1174 lines
46 KiB
Python

"""One place every core HTTP fetch goes through: pooling, merging, budgets, counts.
Stage 1 of the shared fetch service (docs/PLUGIN_API_REFERENCE.md, "Fetching
data"). Core's own fetch paths call :meth:`FetchService.get` instead of
``session.get``:
- ``APIHelper.get`` / ``post`` (``src/common/api_helper.py``);
- ``fetch_espn_scoreboard`` and its date chunks (``src/common/espn_dates.py``),
which every scoreboard plugin's live, recent and upcoming fetch and
``SportsFetchMixin._fetch_season_directly`` already use;
- ``BackgroundDataService`` (season schedules);
- ``BaseOddsManager.get_odds``.
So the plugins that use those helpers are covered without changing a line of
plugin code. What the service adds, around the caller's own ``session.get``:
**Connection pooling.** :func:`share_connection_pool` mounts one shared
``HTTPAdapter`` per retry policy on a caller's Session. urllib3 keeps one
connection pool per host inside it, so every core Session with the same retry
policy reuses the same TCP/TLS connections to a host -- the dozens of
``BaseOddsManager`` instances (one per scoreboard league manager) stop opening
a pool each. Each caller keeps its own Session object, so headers, cookies and
auth -- the mutable parts -- are never shared. :meth:`FetchService.session_for`
is the pooled per-host Session for a caller that has none.
**Single-flight.** Identical GETs in flight at the same time go to the network
once; the others wait and receive a copy of that response (or the same
exception). Identical means the same URL and query, the same effective request
headers, the same timeout and the same retry policy, so a caller can never be
handed a result its own request could not have produced.
**Per-host token buckets.** Each host may have a budget, ``per_second`` with a
``burst``. A request past the budget waits for a token, but never longer than
``max_wait_seconds``: past that it goes anyway, counted as an overrun. A
limiter that stalls a plugin's update budget is worse than one extra request.
The default budgets ESPN only (``*.espn.com``: 20/s, burst 200), far above the
steady-state rate (well under 1/s) and above a cold season fetch; anything else
is unthrottled.
**Conditional GET.** When a 200 carries ``ETag`` or ``Last-Modified``, the
body is kept in a small bounded store and the next identical GET sends
``If-None-Match`` / ``If-Modified-Since``. A ``304`` is turned back into the
200 the caller would have got, body and all, so callers never see a 304 they
did not ask for. Only server-sent validators are used; a caller that sets its
own conditional headers gets the raw answer. (ESPN sent no validators when
this was written -- see the PR that added this module -- so on ESPN the store
stays empty and costs nothing.)
**Counters.** Requests, merged requests, bytes, 304s, errors, HTTP errors,
adapter retries, throttled requests and seconds waited, per plugin and per
host. :class:`FetchStatsPublisher` publishes them for the web interface
(``GET /api/v3/plugins/fetch-stats``).
CALLER IDENTITY
---------------
Counters are kept per plugin without plugins saying who they are:
1. A context variable, set by the core around the code it runs for a plugin:
``PluginExecutor`` around ``update()``/``display()``, ``PluginManager``
around loading a plugin (its constructor and ``on_enable``). The core
carries it across its own thread hops: ``BackgroundDataService`` records
the submitter at submit time and its worker runs under it, and
``espn_dates`` copies it into each chunk thread.
2. When no scope is set -- a thread the plugin started itself, or a call the
display makes outside the executor -- the first stack frame whose source
file lives in a loaded plugin's directory names the plugin
(:func:`register_plugin_directory`).
3. Otherwise the request is the core's own (``"core"``).
Nothing here raises on account of bookkeeping: a failure in counting,
keying or the validator store falls back to a plain ``session.get``.
Core-internal for now (stage 1). Plugins reach it through ``APIHelper`` and
``espn_dates``; a plugin-facing API comes with stage 3.
"""
from __future__ import annotations
import contextlib
import contextvars
import copy
import http.cookiejar
import json
import logging
import math
import os
import sys
import threading
import time
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import (
Any, Callable, Dict, Iterator, List, Mapping, Optional, Tuple,
)
from urllib.parse import urlsplit
import requests
from requests.adapters import HTTPAdapter
from requests.models import PreparedRequest
from requests.sessions import merge_setting
from requests.structures import CaseInsensitiveDict
logger = logging.getLogger(__name__)
__all__ = [
"CORE",
"DEFAULT_CONFIG",
"FETCH_STATS_KEY",
"FetchService",
"FetchStatsPublisher",
"TokenBucket",
"configure_fetch_service",
"current_plugin_id",
"fetch_get",
"fetch_post",
"get_fetch_service",
"pinned_caller",
"plugin_scope",
"read_fetch_stats",
"register_plugin_directory",
"share_connection_pool",
"start_fetch_stats_publisher",
"unregister_plugin_directory",
]
#: The caller name for requests no plugin made.
CORE = "core"
#: Read when the display starts: ``fetch_service`` in config.json, merged over
#: these. ``rate_limits`` maps a host (``api.example.com``) or a suffix pattern
#: (``*.espn.com``, which also matches ``espn.com``) to a budget; a
#: ``per_second`` of 0 or null removes the budget for that host.
DEFAULT_CONFIG: Mapping[str, Any] = {
"enabled": True,
"single_flight": True,
"conditional_get": True,
"max_wait_seconds": 2.0,
"rate_limits": {
"*.espn.com": {"per_second": 20, "burst": 200},
},
"validator_store": {
"max_entries": 64,
"max_bytes": 4 * 1024 * 1024,
"max_entry_bytes": 1024 * 1024,
},
}
#: Connection pools kept per shared adapter (one per host) and connections
#: kept per pool. Larger than requests' 10 because one adapter now serves
#: every core Session with its retry policy: three background workers each
#: running six ESPN chunk threads is 18 concurrent requests to one host.
_POOL_CONNECTIONS = 16
_POOL_MAXSIZE = 24
#: Distinct hosts tracked individually; the rest are counted under "other".
_MAX_HOSTS = 200
_MAX_PLUGINS = 200
_OTHER = "other"
#: How far up the stack the plugin-directory lookup looks.
_MAX_STACK_DEPTH = 80
_COUNTER_FIELDS = (
"requests", # round trips sent (adapter retries inside one are not extra)
"merged", # answered by an identical request already in flight
"not_modified", # 304s turned back into the stored 200
"errors", # transport exceptions (timeouts, connection errors, ...)
"http_errors", # responses with status >= 400
"retries", # retries the urllib3 adapter made inside a request
"throttled", # requests that waited for a host budget
"overruns", # requests that went after max_wait_seconds anyway
"bytes", # decoded response body bytes received
"wait_seconds", # time spent waiting for host budgets
)
# --- caller identity ---------------------------------------------------------
_current_plugin: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
"ledmatrix_fetch_plugin", default=None)
_dirs_lock = threading.Lock()
#: (normalised directory prefix ending in a separator, plugin id), longest
#: prefix first so a nested directory wins.
_plugin_dirs: Tuple[Tuple[str, str], ...] = ()
#: co_filename -> plugin id or "" (not a plugin file). Cleared whenever the
#: registered directories change.
_file_owner: Dict[str, str] = {}
def _norm_dir(path: str) -> str:
text = os.path.normcase(os.path.abspath(path))
return text if text.endswith(os.sep) else text + os.sep
def register_plugin_directory(plugin_id: str, path: Any) -> None:
"""Attribute code under ``path`` to ``plugin_id`` (the stack fallback).
Called by the plugin manager when it loads a plugin. Both the path as
given and its real path are registered: plugin modules import from
``sys.path`` (the directory as given, possibly a dev symlink) and from
the resolved entry point.
"""
global _plugin_dirs
if not plugin_id or path is None:
return
prefixes = {_norm_dir(str(path))}
try:
prefixes.add(_norm_dir(os.path.realpath(str(path))))
except OSError:
pass
with _dirs_lock:
entries = {prefix: pid for prefix, pid in _plugin_dirs if pid != plugin_id}
for prefix in prefixes:
entries[prefix] = plugin_id
_plugin_dirs = tuple(sorted(entries.items(), key=lambda item: -len(item[0])))
_file_owner.clear()
def unregister_plugin_directory(plugin_id: str) -> None:
"""Forget ``plugin_id``'s directories (the plugin was unloaded)."""
global _plugin_dirs
with _dirs_lock:
_plugin_dirs = tuple(item for item in _plugin_dirs if item[1] != plugin_id)
_file_owner.clear()
def _owner_of_file(filename: str, dirs: Tuple[Tuple[str, str], ...]) -> str:
owner = _file_owner.get(filename)
if owner is not None:
return owner
normalised = os.path.normcase(filename)
owner = ""
for prefix, plugin_id in dirs:
if normalised.startswith(prefix):
owner = plugin_id
break
if len(_file_owner) < 10000:
_file_owner[filename] = owner
return owner
def _plugin_from_stack() -> Optional[str]:
dirs = _plugin_dirs
if not dirs:
return None
frame: Any = sys._getframe(1)
depth = 0
while frame is not None and depth < _MAX_STACK_DEPTH:
owner = _owner_of_file(frame.f_code.co_filename, dirs)
if owner:
return owner
frame = frame.f_back
depth += 1
return None
def current_plugin_id() -> Optional[str]:
"""The plugin the current code is running for, or None for the core."""
plugin_id = _current_plugin.get()
if plugin_id:
return plugin_id
try:
return _plugin_from_stack()
except Exception: # never let attribution break a fetch
return None
@contextlib.contextmanager
def plugin_scope(plugin_id: Optional[str]) -> Iterator[None]:
"""Attribute fetches made inside the block (on this thread, and on threads
the core starts from it) to ``plugin_id``. ``None`` leaves the current
attribution alone."""
if not plugin_id:
yield
return
token = _current_plugin.set(plugin_id)
try:
yield
finally:
_current_plugin.reset(token)
@contextlib.contextmanager
def pinned_caller() -> Iterator[None]:
"""Resolve the caller now and pin it into the context, so a context copied
inside the block (``contextvars.copy_context()``) for a worker thread
carries the plugin even when only the stack could name it."""
with plugin_scope(current_plugin_id()):
yield
# --- token bucket -------------------------------------------------------------
class TokenBucket:
"""``per_second`` tokens a second, holding at most ``burst``.
:meth:`reserve` takes a token and says how long the caller must wait for
it. Tokens may go negative -- each caller reserves its own slot -- but
never further than ``max_wait`` seconds' worth, so no request waits
longer than that.
"""
def __init__(self, per_second: float, burst: float,
clock: Callable[[], float] = time.monotonic) -> None:
self.per_second = float(per_second)
self.burst = max(1.0, float(burst))
self._clock = clock
self._tokens = self.burst
self._last = clock()
self._lock = threading.Lock()
def reserve(self, max_wait: float) -> Tuple[float, bool]:
"""Take a token. Returns (seconds to wait, whether max_wait capped it)."""
with self._lock:
now = self._clock()
elapsed = max(0.0, now - self._last)
self._last = now
self._tokens = min(self.burst, self._tokens + elapsed * self.per_second)
self._tokens -= 1.0
if self._tokens >= 0:
return 0.0, False
wait = -self._tokens / self.per_second
if wait > max_wait:
self._tokens = -max_wait * self.per_second
return max(0.0, max_wait), True
return wait, False
@property
def tokens(self) -> float:
with self._lock:
return self._tokens
# --- validator store ------------------------------------------------------------
@dataclass
class _Stored:
etag: Optional[str]
last_modified: Optional[str]
body: bytes
headers: Dict[str, str]
encoding: Optional[str]
class _ValidatorStore:
"""LRU of (body, validators) for responses that carried validators."""
def __init__(self, max_entries: int, max_bytes: int, max_entry_bytes: int) -> None:
self.max_entries = max_entries
self.max_bytes = max_bytes
self.max_entry_bytes = max_entry_bytes
self._entries: "OrderedDict[Any, _Stored]" = OrderedDict()
self._bytes = 0
self._lock = threading.Lock()
def get(self, key: Any) -> Optional[_Stored]:
with self._lock:
entry = self._entries.get(key)
if entry is not None:
self._entries.move_to_end(key)
return entry
def put(self, key: Any, entry: _Stored) -> None:
size = len(entry.body)
with self._lock:
self._drop_locked(key)
if size > self.max_entry_bytes or self.max_entries <= 0:
return
self._entries[key] = entry
self._bytes += size
while self._entries and (len(self._entries) > self.max_entries
or self._bytes > self.max_bytes):
_, old = self._entries.popitem(last=False)
self._bytes -= len(old.body)
def drop(self, key: Any) -> None:
with self._lock:
self._drop_locked(key)
def _drop_locked(self, key: Any) -> None:
old = self._entries.pop(key, None)
if old is not None:
self._bytes -= len(old.body)
def clear(self) -> None:
with self._lock:
self._entries.clear()
self._bytes = 0
def stats(self) -> Dict[str, int]:
with self._lock:
return {"entries": len(self._entries), "bytes": self._bytes}
# --- helpers ------------------------------------------------------------------------
def _host_of(url: Any) -> str:
try:
host = urlsplit(str(url)).hostname
except ValueError:
host = None
return (host or "unknown").lower()
def _prepared_url(url: str, params: Any) -> str:
prepared = PreparedRequest()
prepared.prepare_url(url, params)
return str(prepared.url)
def _retry_fingerprint(retries: Any) -> Tuple[Any, ...]:
"""What makes two urllib3 Retry configurations behave alike."""
names = ("total", "connect", "read", "redirect", "status", "other",
"backoff_factor", "backoff_max", "raise_on_redirect",
"raise_on_status", "respect_retry_after_header")
values: List[Any] = [type(retries).__name__]
for name in names:
values.append(getattr(retries, name, None))
for name in ("status_forcelist", "allowed_methods"):
value = getattr(retries, name, None)
if isinstance(value, (set, frozenset, list, tuple)):
value = tuple(sorted(str(v) for v in value))
values.append(value if value is None or isinstance(value, (tuple, bool)) else repr(value))
return tuple(values)
def _adapter_fingerprint(adapter: Any) -> Tuple[Any, ...]:
if type(adapter) is HTTPAdapter:
return ("HTTPAdapter", _retry_fingerprint(adapter.max_retries))
return ("adapter", id(adapter))
def _header_items(headers: Any) -> Tuple[Tuple[str, str], ...]:
if not headers:
return ()
return tuple(sorted((str(k).lower(), str(v)) for k, v in dict(headers).items()
if v is not None))
def _has_conditional_headers(headers: Any) -> bool:
if not headers:
return False
try:
names = {str(k).lower() for k in dict(headers)}
except (TypeError, ValueError):
return True
return bool(names & {"if-none-match", "if-modified-since", "if-match",
"if-unmodified-since", "if-range"})
def _str_header(response: Any, name: str) -> Optional[str]:
headers = getattr(response, "headers", None)
try:
value = headers.get(name) if headers is not None else None
except Exception:
return None
return value if isinstance(value, str) and value else None
def _status_of(response: Any) -> Optional[int]:
status = getattr(response, "status_code", None)
return status if isinstance(status, int) and not isinstance(status, bool) else None
def _body_of(response: Any) -> Optional[bytes]:
"""The already-read body, or None. Never reads a stream."""
if not isinstance(response, requests.Response):
return None
content = getattr(response, "_content", None)
return content if isinstance(content, bytes) else None
def _retries_of(response: Any) -> int:
raw = getattr(response, "raw", None)
retries = getattr(raw, "retries", None)
history = getattr(retries, "history", None)
return len(history) if isinstance(history, tuple) else 0
def _clone_response(response: Any) -> Any:
"""A copy of a finished response for a merged caller."""
if not isinstance(response, requests.Response):
return response
clone = copy.copy(response)
clone.headers = CaseInsensitiveDict(response.headers)
return clone
def _clone_error(error: BaseException) -> BaseException:
try:
return copy.copy(error)
except Exception:
return error
def _from_stored(entry: _Stored, not_modified: requests.Response) -> requests.Response:
"""The 200 the caller would have got, rebuilt from a 304 and the store."""
response = requests.Response()
response.status_code = 200
response.reason = "OK"
response._content = entry.body
response._content_consumed = True # type: ignore[attr-defined]
headers = CaseInsensitiveDict(entry.headers)
for name, value in (not_modified.headers or {}).items():
if name.lower() not in ("content-length", "content-encoding", "transfer-encoding"):
headers[name] = value
response.headers = headers
response.encoding = entry.encoding
response.url = not_modified.url
response.request = not_modified.request
response.history = not_modified.history
response.elapsed = not_modified.elapsed
response.cookies = not_modified.cookies
response.connection = getattr(not_modified, "connection", None) # type: ignore[assignment]
response.raw = not_modified.raw
return response
def _as_float(value: Any, default: float) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return default
number = float(value)
return number if math.isfinite(number) and number >= 0 else default
def _as_int(value: Any, default: int) -> int:
if isinstance(value, bool) or not isinstance(value, int):
return default
return value if value >= 0 else default
@dataclass
class _Flight:
event: threading.Event = field(default_factory=threading.Event)
response: Any = None
error: Optional[BaseException] = None
#: Callers waiting on this flight (read by tests).
waiters: int = 0
@dataclass
class _Request:
"""What one call resolved to before it goes out."""
plugin: str
host: str
# Same URL + effective headers: what the validator store is keyed by.
representation: Optional[Tuple[Any, ...]] = None
# representation + timeout + retry policy + other kwargs: what merges.
flight: Optional[Tuple[Any, ...]] = None
# --- the service ----------------------------------------------------------------------
class FetchService:
"""See the module docstring. One per process: :func:`get_fetch_service`."""
def __init__(self, config: Any = None, *,
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
wall_clock: Callable[[], float] = time.time) -> None:
self._clock = clock
self._sleep = sleep
self._wall_clock = wall_clock
self.started_at = wall_clock()
self._lock = threading.Lock()
self._inflight: Dict[Tuple[Any, ...], _Flight] = {}
self._buckets: Dict[str, Optional[TokenBucket]] = {}
self._adapters: Dict[Tuple[Any, ...], HTTPAdapter] = {}
self._sessions: Dict[str, requests.Session] = {}
self._plugins: Dict[str, Dict[str, float]] = {}
self._plugin_hosts: Dict[str, Dict[str, int]] = {}
self._hosts: Dict[str, Dict[str, float]] = {}
self._totals: Dict[str, float] = dict.fromkeys(_COUNTER_FIELDS, 0)
#: Bumped on every counter change; the publisher compares it.
self.change_count = 0
self.enabled = True
self.single_flight = True
self.conditional_get = True
self.max_wait_seconds = 2.0
self._rate_limits: Dict[str, Tuple[float, float]] = {}
self._validators = _ValidatorStore(0, 0, 0)
self._applied: Optional[str] = None
self.configure(config)
# -- configuration --
def configure(self, config: Any = None) -> None:
"""Apply ``config`` (the ``fetch_service`` section) over DEFAULT_CONFIG.
Bad values are ignored with a warning; this never raises. Applying
the same section again is a no-op: a config hot reload for any other
setting keeps the budgets and the validator store."""
try:
signature = json.dumps(config, sort_keys=True, default=repr)
except (TypeError, ValueError):
signature = repr(config)
if signature == self._applied:
return
self._applied = signature
merged: Dict[str, Any] = dict(DEFAULT_CONFIG)
if isinstance(config, Mapping):
merged.update({k: v for k, v in config.items() if v is not None})
elif config is not None:
logger.warning("fetch_service config is not an object; using the defaults")
self.enabled = merged.get("enabled") is not False
self.single_flight = merged.get("single_flight") is not False
self.conditional_get = merged.get("conditional_get") is not False
self.max_wait_seconds = _as_float(merged.get("max_wait_seconds"),
float(DEFAULT_CONFIG["max_wait_seconds"]))
limits: Dict[str, Tuple[float, float]] = {}
raw_limits = merged.get("rate_limits")
if isinstance(raw_limits, Mapping):
for pattern, spec in raw_limits.items():
if not isinstance(pattern, str) or not pattern.strip():
continue
if spec is None:
continue
if not isinstance(spec, Mapping):
logger.warning("fetch_service.rate_limits[%r] is not an object; ignored", pattern)
continue
per_second = _as_float(spec.get("per_second"), -1.0)
if spec.get("per_second") in (None, 0):
continue # explicitly unthrottled
if per_second <= 0:
logger.warning("fetch_service.rate_limits[%r].per_second must be a "
"positive number; ignored", pattern)
continue
burst = _as_float(spec.get("burst"), max(1.0, per_second))
limits[pattern.strip().lower()] = (per_second, max(1.0, burst))
elif raw_limits is not None:
logger.warning("fetch_service.rate_limits is not an object; no host budgets")
store = merged.get("validator_store")
store = store if isinstance(store, Mapping) else {}
default_store = DEFAULT_CONFIG["validator_store"]
with self._lock:
self._rate_limits = limits
self._buckets.clear()
self._validators = _ValidatorStore(
_as_int(store.get("max_entries"), default_store["max_entries"]),
_as_int(store.get("max_bytes"), default_store["max_bytes"]),
_as_int(store.get("max_entry_bytes"), default_store["max_entry_bytes"]),
)
self.change_count += 1
def describe_config(self) -> Dict[str, Any]:
with self._lock:
limits = {pattern: {"per_second": rate, "burst": burst}
for pattern, (rate, burst) in sorted(self._rate_limits.items())}
return {
"enabled": self.enabled,
"single_flight": self.single_flight,
"conditional_get": self.conditional_get,
"max_wait_seconds": self.max_wait_seconds,
"rate_limits": limits,
}
def _limit_for(self, host: str) -> Optional[Tuple[float, float]]:
limits = self._rate_limits
exact = limits.get(host)
if exact is not None:
return exact
best: Optional[Tuple[float, float]] = None
best_len = -1
for pattern, limit in limits.items():
if pattern.startswith("*."):
suffix = pattern[1:] # ".espn.com"
if (host.endswith(suffix) or host == suffix[1:]) and len(suffix) > best_len:
best, best_len = limit, len(suffix)
return best
def _bucket_for(self, host: str) -> Optional[TokenBucket]:
with self._lock:
if host in self._buckets:
return self._buckets[host]
limit = self._limit_for(host)
bucket = None if limit is None else TokenBucket(limit[0], limit[1], self._clock)
if len(self._buckets) < 1000:
self._buckets[host] = bucket
return bucket
# -- pooling --
def shared_adapter(self, max_retries: Any = 0) -> HTTPAdapter:
"""The shared adapter for a retry policy (an int or a urllib3 Retry)."""
probe = HTTPAdapter(max_retries=max_retries)
key = _retry_fingerprint(probe.max_retries)
with self._lock:
adapter = self._adapters.get(key)
if adapter is None:
adapter = HTTPAdapter(pool_connections=_POOL_CONNECTIONS,
pool_maxsize=_POOL_MAXSIZE,
max_retries=max_retries)
self._adapters[key] = adapter
return adapter
def session_for(self, url: str) -> requests.Session:
"""The pooled Session for ``url``'s host, for a caller without one.
Stateless on purpose -- it is shared by every such caller -- so it
accepts no cookies; pass headers per request.
"""
host = _host_of(url)
with self._lock:
session = self._sessions.get(host)
if session is not None:
return session
session = requests.Session()
session.cookies.set_policy(http.cookiejar.DefaultCookiePolicy(allowed_domains=[]))
try:
from src.common.api_helper import DEFAULT_HTTP_HEADERS
session.headers.update(DEFAULT_HTTP_HEADERS)
except ImportError: # pragma: no cover - always importable in core
pass
adapter = self.shared_adapter(0)
session.mount("https://", adapter)
session.mount("http://", adapter)
with self._lock:
return self._sessions.setdefault(host, session)
# -- requests --
def get(self, session: Any, url: str, *, share_in_flight: bool = True,
**kwargs: Any) -> Any:
"""``session.get(url, **kwargs)`` through the service.
Same return value, same exceptions, and ``session.get`` is called
with the caller's own arguments (plus validator headers when the
store holds some). ``session=None`` uses :meth:`session_for`.
``share_in_flight=False`` always sends this request itself rather
than joining an identical one in flight -- for a caller that may
retry *because* an earlier request hung (BackgroundDataService
cancels and replaces a fetch) and must not be handed that one.
"""
transport = session if session is not None else self.session_for(url)
if not self.enabled:
return transport.get(url, **kwargs)
try:
request = self._resolve(transport, url, kwargs)
except Exception:
logger.debug("fetch_service could not key a request to %s", url, exc_info=True)
request = _Request(plugin=self._caller(), host=_host_of(url))
if request.flight is None or not self.single_flight or not share_in_flight:
return self._send_get(transport, url, kwargs, request)
with self._lock:
flight = self._inflight.get(request.flight)
leader = flight is None
if flight is None:
flight = _Flight()
self._inflight[request.flight] = flight
else:
flight.waiters += 1
if not leader:
flight.event.wait()
self._count(request.plugin, request.host, merged=1)
if flight.error is not None:
raise _clone_error(flight.error)
return _clone_response(flight.response)
try:
response = self._send_get(transport, url, kwargs, request)
flight.response = response
return response
except BaseException as error:
flight.error = error
raise
finally:
with self._lock:
if self._inflight.get(request.flight) is flight:
del self._inflight[request.flight]
flight.event.set()
def post(self, session: Any, url: str, **kwargs: Any) -> Any:
"""``session.post(url, **kwargs)``: counted and budgeted, never merged."""
transport = session if session is not None else self.session_for(url)
if not self.enabled:
return transport.post(url, **kwargs)
request = _Request(plugin=self._caller(), host=_host_of(url))
return self._send(lambda: transport.post(url, **kwargs), request)
def note_merged(self, url: Any = None, plugin_id: Optional[str] = None) -> None:
"""Count a request answered by another caller's fetch outside this
service (BackgroundDataService joining an in-flight cache key)."""
try:
self._count(plugin_id or self._caller(), _host_of(url), merged=1)
except Exception:
logger.debug("fetch_service could not count a merged request", exc_info=True)
def _caller(self) -> str:
return current_plugin_id() or CORE
def _resolve(self, transport: Any, url: str, kwargs: Dict[str, Any]) -> _Request:
request = _Request(plugin=self._caller(), host=_host_of(url))
if kwargs.get("stream"):
return request # body not read here: nothing to share or store
full_url = _prepared_url(url, kwargs.get("params"))
headers = kwargs.get("headers")
identity = getattr(transport, "fetch_identity_session", None)
session = identity if isinstance(identity, requests.Session) else transport
if isinstance(session, requests.Session):
effective = merge_setting(headers, session.headers, dict_class=CaseInsensitiveDict)
header_items = _header_items(effective)
policy: Tuple[Any, ...] = (type(transport).__name__,
_adapter_fingerprint(session.get_adapter(full_url)))
stateful = bool(session.cookies) or session.auth is not None
owner = id(session) if stateful else None
else:
header_items = _header_items(headers)
policy = (type(transport).__name__, id(transport))
owner = id(transport)
request.representation = (full_url, header_items, owner)
others = tuple(sorted((k, repr(v)) for k, v in kwargs.items()
if k not in ("params", "headers")))
request.flight = ("GET", request.representation, policy, others)
return request
def _send_get(self, transport: Any, url: str, kwargs: Dict[str, Any],
request: _Request) -> Any:
stored: Optional[_Stored] = None
call_kwargs = kwargs
conditional = (self.conditional_get and request.representation is not None
and not _has_conditional_headers(kwargs.get("headers")))
if conditional:
stored = self._validators.get(request.representation)
if stored is not None:
extra: Dict[str, str] = {}
if stored.etag:
extra["If-None-Match"] = stored.etag
if stored.last_modified:
extra["If-Modified-Since"] = stored.last_modified
headers = kwargs.get("headers")
merged_headers: Any = (CaseInsensitiveDict(headers) if headers
else CaseInsensitiveDict())
merged_headers.update(extra)
call_kwargs = dict(kwargs, headers=merged_headers)
response = self._send(lambda: transport.get(url, **call_kwargs), request,
stored=stored)
if conditional:
try:
response = self._after_conditional(request, response, stored)
except Exception:
logger.debug("fetch_service validator bookkeeping failed for %s", url,
exc_info=True)
return response
def _after_conditional(self, request: _Request, response: Any,
stored: Optional[_Stored]) -> Any:
status = _status_of(response)
key = request.representation
if status == 304 and stored is not None and isinstance(response, requests.Response):
return _from_stored(stored, response)
if status == 200:
body = _body_of(response)
etag = _str_header(response, "ETag")
last_modified = _str_header(response, "Last-Modified")
no_store = "no-store" in (_str_header(response, "Cache-Control") or "").lower()
if body is not None and (etag or last_modified) and not no_store:
self._validators.put(key, _Stored(
etag=etag, last_modified=last_modified, body=body,
headers=dict(response.headers), encoding=response.encoding))
else:
self._validators.drop(key)
return response
def _send(self, call: Callable[[], Any], request: _Request,
stored: Optional[_Stored] = None) -> Any:
waited = 0.0
overrun = False
try:
bucket = self._bucket_for(request.host)
if bucket is not None:
waited, overrun = bucket.reserve(self.max_wait_seconds)
except Exception:
logger.debug("fetch_service budget check failed", exc_info=True)
waited, overrun = 0.0, False
if waited > 0:
self._sleep(waited)
try:
response = call()
except BaseException:
self._count(request.plugin, request.host, requests=1, errors=1,
throttled=int(waited > 0), overruns=int(overrun),
wait_seconds=waited)
raise
try:
status = _status_of(response)
body = _body_of(response)
not_modified = int(status == 304 and stored is not None)
self._count(request.plugin, request.host, requests=1,
not_modified=not_modified,
http_errors=int(status is not None and status >= 400),
retries=_retries_of(response),
bytes=len(body) if body is not None else 0,
throttled=int(waited > 0), overruns=int(overrun),
wait_seconds=waited)
except Exception:
logger.debug("fetch_service could not count a response", exc_info=True)
return response
# -- counters --
def _count(self, plugin: str, host: str, **deltas: float) -> None:
"""Add ``deltas`` to the plugin's, the host's and the totals. Never
raises: it runs on every fetch's success and failure paths."""
try:
self._apply_counts(plugin, host, deltas)
except Exception:
logger.debug("fetch_service could not update its counters", exc_info=True)
def _apply_counts(self, plugin: str, host: str, deltas: Dict[str, float]) -> None:
changes = {k: v for k, v in deltas.items() if v}
if not changes:
return
with self._lock:
if plugin not in self._plugins and len(self._plugins) >= _MAX_PLUGINS:
plugin = _OTHER
if host not in self._hosts and len(self._hosts) >= _MAX_HOSTS:
host = _OTHER
per_plugin = self._plugins.setdefault(plugin, dict.fromkeys(_COUNTER_FIELDS, 0))
per_host = self._hosts.setdefault(host, dict.fromkeys(_COUNTER_FIELDS, 0))
for name, value in changes.items():
per_plugin[name] += value
per_host[name] += value
self._totals[name] += value
if changes.get("requests") or changes.get("merged"):
hosts = self._plugin_hosts.setdefault(plugin, {})
hosts[host] = hosts.get(host, 0) + int(changes.get("requests", 0)
+ changes.get("merged", 0))
self.change_count += 1
def reset_counters(self) -> None:
with self._lock:
self._plugins.clear()
self._plugin_hosts.clear()
self._hosts.clear()
self._totals = dict.fromkeys(_COUNTER_FIELDS, 0)
self.change_count += 1
def reset(self) -> None:
"""Counters, validators, budgets and in-flight table (tests)."""
self.reset_counters()
with self._lock:
self._buckets.clear()
self._inflight.clear()
self._validators.clear()
def snapshot(self) -> Dict[str, Any]:
"""Counters since the service started, JSON-ready."""
def rounded(counters: Mapping[str, float]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
for name in _COUNTER_FIELDS:
value = counters.get(name, 0)
out[name] = round(float(value), 3) if name == "wait_seconds" else int(value)
return out
with self._lock:
plugins = {pid: dict(rounded(c), hosts=dict(self._plugin_hosts.get(pid, {})))
for pid, c in self._plugins.items()}
hosts = {host: rounded(c) for host, c in self._hosts.items()}
totals = rounded(self._totals)
change = self.change_count
return {
"since": self.started_at,
"change_count": change,
"totals": totals,
"plugins": plugins,
"hosts": hosts,
"validators": self._validators.stats(),
"config": self.describe_config(),
}
# --- process-wide instance ---------------------------------------------------------
_service: Optional[FetchService] = None
_service_lock = threading.Lock()
def get_fetch_service() -> FetchService:
"""The process's FetchService, created with the defaults on first use."""
global _service
service = _service
if service is not None:
return service
with _service_lock:
if _service is None:
_service = FetchService()
return _service
def configure_fetch_service(config: Any) -> FetchService:
"""Apply config.json's ``fetch_service`` section. Never raises."""
service = get_fetch_service()
try:
service.configure(config)
except Exception:
logger.warning("Could not apply the fetch_service config; keeping the defaults",
exc_info=True)
return service
def fetch_get(session: Any, url: str, *, share_in_flight: bool = True,
**kwargs: Any) -> Any:
"""``session.get(url, **kwargs)`` through the process's FetchService."""
return get_fetch_service().get(session, url, share_in_flight=share_in_flight,
**kwargs)
def fetch_post(session: Any, url: str, **kwargs: Any) -> Any:
"""``session.post(url, **kwargs)`` through the process's FetchService."""
return get_fetch_service().post(session, url, **kwargs)
def share_connection_pool(session: Any, max_retries: Any = 0) -> None:
"""Mount the shared adapter for ``max_retries`` on ``session``'s http and
https prefixes, replacing what was there. Pass the same retry policy the
Session would otherwise mount: only the pool becomes shared. A no-op for
anything that is not a requests Session (a test double)."""
if not isinstance(session, requests.Session):
return
adapter = get_fetch_service().shared_adapter(max_retries)
session.mount("https://", adapter)
session.mount("http://", adapter)
# --- publishing (display) and reading (web) ----------------------------------------
FETCH_STATS_KEY = "fetch_stats_snapshot"
SNAPSHOT_SCHEMA = 1
#: Shortest gap between two writes: the snapshot is on the SD card.
MIN_INTERVAL = 60.0
#: An unchanged snapshot is rewritten this often so readers can tell a quiet
#: display from a dead one, and the cache's retention sweep keeps it.
REFRESH_INTERVAL = 600.0
TICK_INTERVAL = 15.0
STALE_AFTER = REFRESH_INTERVAL + 2 * MIN_INTERVAL
LIVE = "live"
STALE = "stale"
STOPPED = "stopped"
UNKNOWN = "unknown"
class FetchStatsPublisher:
"""Publishes the service's counters to the shared cache (display only).
Written when the counters changed, at most once every ``min_interval``
seconds, and otherwise every ``refresh_interval`` seconds. Never raises.
"""
def __init__(self, cache_manager: Any, service: Optional[FetchService] = None,
min_interval: float = MIN_INTERVAL,
refresh_interval: float = REFRESH_INTERVAL,
clock: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time) -> None:
self.cache_manager = cache_manager
self.service = service or get_fetch_service()
self.min_interval = min_interval
self.refresh_interval = refresh_interval
self._clock = clock
self._wall_clock = wall_clock
self._published_change: Optional[int] = None
self._last_attempt: Optional[float] = None
self._tick_lock = threading.Lock()
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
def _write(self, running: bool) -> None:
snapshot = self.service.snapshot()
snapshot.update({
"schema": SNAPSHOT_SCHEMA,
"running": running,
"published_at": self._wall_clock(),
"stale_after": STALE_AFTER,
"pid": os.getpid(),
})
self.cache_manager.set(FETCH_STATS_KEY, snapshot)
def tick(self) -> bool:
"""Publish if due. True if a snapshot was written."""
with self._tick_lock:
try:
change = self.service.change_count
now = self._clock()
if self._last_attempt is not None:
since = now - self._last_attempt
if change == self._published_change:
if since < self.refresh_interval:
return False
elif since < self.min_interval:
return False
self._last_attempt = now
self._write(running=True)
self._published_change = change
return True
except Exception as err:
logger.debug("Could not publish fetch stats: %s", err, exc_info=True)
return False
def start(self, interval: float = TICK_INTERVAL) -> None:
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
def run() -> None:
# First write replaces whatever a previous run left behind.
self.tick()
while not self._stop.wait(interval):
self.tick()
self._thread = threading.Thread(target=run, name="fetch-stats-publisher", daemon=True)
self._thread.start()
def stop(self, publish_stopped: bool = True) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=2)
self._thread = None
if publish_stopped:
with self._tick_lock:
try:
self._write(running=False)
except Exception as err:
logger.debug("Could not publish stopped fetch stats: %s", err, exc_info=True)
def start_fetch_stats_publisher(cache_manager: Any) -> Optional[FetchStatsPublisher]:
"""Start publishing this process's fetch counters. Display service only.
Never raises."""
try:
publisher = FetchStatsPublisher(cache_manager)
publisher.start()
return publisher
except Exception as err:
logger.warning("Fetch statistics for the web interface are unavailable: %s", err)
return None
def read_fetch_stats(cache_manager: Any, now: Optional[float] = None) -> Dict[str, Any]:
"""The display's latest counters, judged for staleness. Never raises.
``status`` is ``live``, ``stale`` (older than its ``stale_after``),
``stopped`` (the display said so on exit; the final counters are still
given) or ``unknown`` (no readable snapshot). ``data`` is the snapshot,
or None when unknown.
"""
if cache_manager is None:
return {"status": UNKNOWN, "age_seconds": None, "data": None}
try:
snapshot = cache_manager.get(FETCH_STATS_KEY, max_age=None, memory_ttl=0)
except Exception as err:
logger.debug("Could not read fetch stats: %s", err, exc_info=True)
snapshot = None
if not isinstance(snapshot, dict) or snapshot.get("schema") != SNAPSHOT_SCHEMA:
return {"status": UNKNOWN, "age_seconds": None, "data": None}
published_at = snapshot.get("published_at")
if isinstance(published_at, bool) or not isinstance(published_at, (int, float)):
return {"status": UNKNOWN, "age_seconds": None, "data": None}
stale_after = _as_float(snapshot.get("stale_after"), STALE_AFTER) or STALE_AFTER
age = (time.time() if now is None else now) - float(published_at)
if snapshot.get("running") is not True:
status = STOPPED
elif age > stale_after or age < -stale_after:
status = STALE
else:
status = LIVE
return {"status": status, "age_seconds": round(age, 1), "data": snapshot}