mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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>
This commit is contained in:
@@ -19,6 +19,52 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Shared fetch service (stage 1)
|
||||
|
||||
Core's own HTTP fetch paths now go through one service, so the plugins that
|
||||
use them get pooling, merging, host budgets and per-plugin request counts
|
||||
without a code change. Return values, exceptions, cache keys, TTLs and retry
|
||||
policies are unchanged.
|
||||
|
||||
- **What goes through it.** `APIHelper.get`/`post`, `fetch_espn_scoreboard`
|
||||
and its date chunks (`src/common/espn_dates.py` -- every scoreboard's live,
|
||||
recent and upcoming fetch, and `SportsFetchMixin._fetch_season_directly`),
|
||||
`BackgroundDataService` and `BaseOddsManager.get_odds`. Plugins' own
|
||||
`requests` calls are not covered yet.
|
||||
- **Shared connection pools.** Core sessions with the same retry policy mount
|
||||
one shared adapter, so the odds managers (one per scoreboard league
|
||||
manager), the background service and the APIHelpers reuse one connection
|
||||
pool per host. Headers, cookies and auth stay per session.
|
||||
- **Merged requests.** Identical GETs in flight at once (same URL and query,
|
||||
effective headers, timeout and retry policy) go out once; the others get a
|
||||
copy of that response or the same exception. `BackgroundDataService`'s own
|
||||
request opts out (`share_in_flight=False`): it cancels and replaces fetches,
|
||||
and already merges by cache key.
|
||||
- **Host budgets.** Per-host token buckets, `fetch_service.rate_limits` in
|
||||
`config.json` (new optional section in the template). ESPN hosts default to
|
||||
20 requests/s with a burst of 200, far above normal traffic; no request waits
|
||||
longer than `max_wait_seconds` (2 s). Other hosts are unthrottled.
|
||||
- **Conditional GET.** A response with `ETag` or `Last-Modified` is kept in a
|
||||
small bounded store (64 entries, 4 MB, 1 MB each) and revalidated; a `304`
|
||||
is returned to the caller as the original `200`. ESPN sends neither
|
||||
validator today, so on ESPN this is dormant.
|
||||
- **Counters.** Requests, merged, bytes, 304s, errors, HTTP errors, adapter
|
||||
retries, throttled requests and seconds waited, per plugin and per host.
|
||||
Which plugin made a request comes from a context variable the plugin
|
||||
executor and plugin loader set (carried across the background service's and
|
||||
`espn_dates`' worker threads), or else from the plugin directory on the
|
||||
stack, so a plugin's own threads count too. The display publishes them to
|
||||
the shared cache at most once a minute on change; read them at
|
||||
`GET /api/v3/plugins/fetch-stats`.
|
||||
- `fetch_service` is a core config section (`src/core_config_keys.py`).
|
||||
|
||||
### New modules
|
||||
|
||||
- `src/common/fetch_service.py` -- the fetch service above. Core-internal in
|
||||
this release: plugins reach it through `APIHelper` and `espn_dates`, and
|
||||
should not import it directly until a plugin-facing API ships (stage 3), so
|
||||
it sets no `ledmatrix_min_version` floor.
|
||||
|
||||
### Tooling
|
||||
|
||||
- Golden trace tests for the display loop. `test/test_run_loop_golden.py`
|
||||
|
||||
@@ -174,6 +174,16 @@
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos"
|
||||
},
|
||||
"fetch_service": {
|
||||
"enabled": true,
|
||||
"max_wait_seconds": 2,
|
||||
"rate_limits": {
|
||||
"*.espn.com": {
|
||||
"per_second": 20,
|
||||
"burst": 200
|
||||
}
|
||||
}
|
||||
},
|
||||
"web-ui-info": {
|
||||
"enabled": true,
|
||||
"display_duration": 10
|
||||
|
||||
@@ -48,6 +48,7 @@ each other. They share three things:
|
||||
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
|
||||
| Error clear | cache `plugin_error_clear_request` | web | display |
|
||||
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
|
||||
| Fetch statistics (requests per plugin and host) | cache `fetch_stats_snapshot` | display: `FetchStatsPublisher` ([`src/common/fetch_service.py`](../src/common/fetch_service.py)), at most once a minute on change | web: `read_fetch_stats()` for `/api/v3/plugins/fetch-stats` |
|
||||
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
|
||||
| Plugin runtime (loaded, state, last error, version) | cache `plugin_runtime_snapshot` | display: `PluginRuntimePublisher` ([`src/plugin_system/plugin_runtime.py`](../src/plugin_system/plugin_runtime.py)) | web: `read_plugin_runtime()` for `/api/v3/plugins/installed`, `/plugins/state`, reconciliation |
|
||||
| Preview frame | `/tmp/led_matrix_preview.png` | display: `DisplayManager`, gated by [`snapshot_policy`](../src/common/snapshot_policy.py) | web: display SSE stream, `/api/v3/health` (file age) |
|
||||
|
||||
@@ -14,6 +14,7 @@ Complete API reference for plugin developers. This document describes all method
|
||||
- [Display Manager](#display-manager)
|
||||
- [Cache Manager](#cache-manager)
|
||||
- [Plugin Manager](#plugin-manager)
|
||||
- [Fetching data](#fetching-data)
|
||||
- [Deprecated APIs](#deprecated-apis)
|
||||
|
||||
---
|
||||
@@ -1030,6 +1031,63 @@ if weather is not None and weather.enabled:
|
||||
|
||||
---
|
||||
|
||||
## Fetching data
|
||||
|
||||
Use the core helpers for HTTP rather than a `requests.Session` of your own:
|
||||
`APIHelper` (`from src.common import APIHelper`) for JSON APIs, and
|
||||
`fetch_espn_scoreboard()` (`src.common.espn_dates`) or
|
||||
`BackgroundDataService` for ESPN scoreboards. Since the release after 3.7.0
|
||||
these go through the core **fetch service** (`src/common/fetch_service.py`),
|
||||
so a plugin that uses them gets the following with no code change. Return
|
||||
values, exceptions and retries are what they were.
|
||||
|
||||
- **Shared connections.** Core sessions with the same retry policy share one
|
||||
connection pool per host, instead of one pool per helper.
|
||||
- **Merged requests.** Identical GETs in flight at the same time (same URL
|
||||
and query, headers, timeout and retry policy) go to the network once, and
|
||||
every caller gets its own copy of the response, or the same exception.
|
||||
- **Host budgets.** A host can have a token-bucket budget. A request past it
|
||||
waits for a token, but never longer than `max_wait_seconds` (2 s by
|
||||
default). Only ESPN hosts have one by default (20 requests a second, burst
|
||||
200), which normal use never reaches.
|
||||
- **Conditional GET.** When a server sends `ETag` or `Last-Modified`, the
|
||||
next identical request revalidates, and a `304 Not Modified` comes back to
|
||||
your code as the original `200` with its body. ESPN currently sends
|
||||
neither, so this does nothing there.
|
||||
- **Counters.** Requests, merged requests, bytes, 304s, errors and time spent
|
||||
waiting are counted per plugin and per host, and published for the web UI
|
||||
at `GET /api/v3/plugins/fetch-stats` (see
|
||||
[REST_API_REFERENCE.md](REST_API_REFERENCE.md#get-fetch-statistics)). A
|
||||
request is counted against your plugin when it runs inside your
|
||||
`update()`/`display()`, your constructor or `on_enable()`, or anywhere in
|
||||
code under your plugin's directory, including threads you start.
|
||||
|
||||
What is not covered yet: requests a plugin makes with its own `requests.get()`
|
||||
or `Session.get()` calls. They work as before but are invisible to the
|
||||
budgets and counters.
|
||||
|
||||
The settings live in `config.json` under `fetch_service`, read when the
|
||||
display starts and on a config reload:
|
||||
|
||||
```json
|
||||
"fetch_service": {
|
||||
"enabled": true,
|
||||
"max_wait_seconds": 2,
|
||||
"rate_limits": {
|
||||
"*.espn.com": {"per_second": 20, "burst": 200},
|
||||
"api.example.com": {"per_second": 1, "burst": 5}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`rate_limits` keys are a host or a `*.domain` pattern (which also matches
|
||||
the bare domain); `"per_second": 0` removes a budget. `"enabled": false`
|
||||
turns the whole service into a plain `session.get()`. Two further switches,
|
||||
`"single_flight": false` and `"conditional_get": false`, turn off merging and
|
||||
revalidation.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Caching
|
||||
|
||||
@@ -980,6 +980,63 @@ Metrics for one plugin; `data` has the same fields as one entry above.
|
||||
|
||||
Reset metrics for a plugin.
|
||||
|
||||
### Get Fetch Statistics
|
||||
|
||||
**GET** `/api/v3/plugins/fetch-stats`
|
||||
|
||||
Network requests made through the core fetch service
|
||||
(`src/common/fetch_service.py`), per plugin and per host, cumulative since
|
||||
the display started. Read-only. The display publishes the counters at most
|
||||
once a minute when they change (every 10 minutes otherwise), so they can be
|
||||
up to a minute old. Requests a plugin makes with its own `requests` calls,
|
||||
outside `APIHelper`, `espn_dates`, `BackgroundDataService` and
|
||||
`BaseOddsManager`, are not counted yet.
|
||||
|
||||
`data.status` is `live`, `stale` (no publish for longer than
|
||||
`stale_after`), `stopped` (the display exited; the last counters are kept)
|
||||
or `unknown` (nothing published; `data.data` is `null`).
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"status": "live",
|
||||
"age_seconds": 12.4,
|
||||
"data": {
|
||||
"schema": 1,
|
||||
"running": true,
|
||||
"published_at": 1790000000.0,
|
||||
"stale_after": 720.0,
|
||||
"since": 1789990000.0,
|
||||
"totals": {"requests": 412, "merged": 3, "not_modified": 0,
|
||||
"errors": 1, "http_errors": 2, "retries": 0,
|
||||
"throttled": 0, "overruns": 0, "bytes": 18234011,
|
||||
"wait_seconds": 0.0},
|
||||
"plugins": {
|
||||
"football-scoreboard": {"requests": 240, "merged": 2, "bytes": 9120330,
|
||||
"hosts": {"site.api.espn.com": 180,
|
||||
"sports.core.api.espn.com": 62},
|
||||
"...": "the other counters, as in totals"}
|
||||
},
|
||||
"hosts": {
|
||||
"site.api.espn.com": {"requests": 301, "...": "as in totals"}
|
||||
},
|
||||
"validators": {"entries": 0, "bytes": 0},
|
||||
"config": {"enabled": true, "single_flight": true,
|
||||
"conditional_get": true, "max_wait_seconds": 2.0,
|
||||
"rate_limits": {"*.espn.com": {"per_second": 20.0, "burst": 200.0}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`requests` counts round trips sent (retries inside the HTTP adapter are in
|
||||
`retries`), `merged` requests answered by an identical one already in
|
||||
flight, `not_modified` 304s served from the stored body, `errors` transport
|
||||
failures and `http_errors` responses with status 400 or above. `bytes` is the
|
||||
decoded body size. `core` is everything no plugin made.
|
||||
|
||||
### Get/Set Plugin Limits
|
||||
|
||||
**GET** `/api/v3/plugins/limits/<plugin_id>`
|
||||
|
||||
@@ -22,6 +22,7 @@ src/common/api_helper.py
|
||||
src/common/bdf_font.py
|
||||
src/common/espn_dates.py
|
||||
src/common/favorite_team_check.py
|
||||
src/common/fetch_service.py
|
||||
src/common/font_layout.py
|
||||
src/common/frame_timing.py
|
||||
src/common/json_body.py
|
||||
|
||||
@@ -27,6 +27,13 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
import pytz
|
||||
from src.cache_manager import CacheManager
|
||||
from src.common.json_body import response_json
|
||||
from src.common.fetch_service import (
|
||||
current_plugin_id,
|
||||
fetch_get,
|
||||
get_fetch_service,
|
||||
plugin_scope,
|
||||
share_connection_pool,
|
||||
)
|
||||
from src.common.espn_dates import (
|
||||
RANGE_RETRY_SECONDS,
|
||||
_note_range_rejected,
|
||||
@@ -78,6 +85,9 @@ class FetchRequest:
|
||||
commit_claimed: bool = False
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
# The plugin that submitted the request, so the fetch service counts the
|
||||
# worker's requests against it (fetch_service, caller identity).
|
||||
owner: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
@@ -119,6 +129,12 @@ class _ConnectionRetryingSession:
|
||||
def __init__(self, session):
|
||||
self._session = session
|
||||
|
||||
@property
|
||||
def fetch_identity_session(self):
|
||||
"""The wrapped Session, whose headers and adapter the fetch service
|
||||
reads to key this request (src/common/fetch_service.py)."""
|
||||
return self._session
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
for attempt in range(self.ATTEMPTS):
|
||||
try:
|
||||
@@ -196,9 +212,12 @@ class BackgroundDataService:
|
||||
# connection errors three times, a dead network cost up to 16
|
||||
# connection attempts per request and held one of the few worker
|
||||
# threads for all of them.
|
||||
#
|
||||
# The adapter is the fetch service's shared no-retry one: the same
|
||||
# max_retries=0, with the connection pool shared with the other core
|
||||
# sessions that do not retry (the odds managers).
|
||||
self.session = requests.Session()
|
||||
self.session.mount('http://', requests.adapters.HTTPAdapter(max_retries=0))
|
||||
self.session.mount('https://', requests.adapters.HTTPAdapter(max_retries=0))
|
||||
share_connection_pool(self.session, max_retries=0)
|
||||
|
||||
# Default headers: core's shared set (real User-Agent, no hand-set
|
||||
# Accept-Encoding) -- see src/common/api_helper.py.
|
||||
@@ -299,6 +318,10 @@ class BackgroundDataService:
|
||||
if url.split('?', 1)[0].rstrip('/').endswith('/scoreboard'):
|
||||
params = clamp_espn_limit(params)
|
||||
|
||||
# Who asked, resolved on the submitting thread: the worker thread
|
||||
# runs no plugin code, so it could not tell (fetch_service).
|
||||
owner = current_plugin_id()
|
||||
|
||||
# Create fetch request
|
||||
request = FetchRequest(
|
||||
id=request_id,
|
||||
@@ -311,7 +334,8 @@ class BackgroundDataService:
|
||||
timeout=timeout or self.request_timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=callback
|
||||
callback=callback,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
@@ -330,6 +354,7 @@ class BackgroundDataService:
|
||||
self.stats['deduplicated_requests'] = (
|
||||
self.stats.get('deduplicated_requests', 0) + 1
|
||||
)
|
||||
get_fetch_service().note_merged(url, owner)
|
||||
logger.info(
|
||||
"Joined in-flight fetch %s for %s (cache_key=%s) instead of "
|
||||
"starting a duplicate", existing_id, sport, cache_key
|
||||
@@ -357,6 +382,11 @@ class BackgroundDataService:
|
||||
Returns:
|
||||
Fetch result with data or error information
|
||||
"""
|
||||
with plugin_scope(request.owner):
|
||||
return self._fetch_data_worker_scoped(request)
|
||||
|
||||
def _fetch_data_worker_scoped(self, request: FetchRequest) -> FetchResult:
|
||||
"""_fetch_data_worker's body, run with the submitter as the caller."""
|
||||
start_time = time.time()
|
||||
result = FetchResult(request_id=request.id, success=False, retry_count=request.retry_count)
|
||||
|
||||
@@ -621,8 +651,14 @@ class BackgroundDataService:
|
||||
|
||||
for attempt in range(request.max_retries + 1):
|
||||
try:
|
||||
response = self.session.get(
|
||||
# Not shared with an identical request in flight: this
|
||||
# service cancels and replaces fetches, and a replacement
|
||||
# must not join the one it replaced. Its own cache_key
|
||||
# dedup already merges what should be merged.
|
||||
response = fetch_get(
|
||||
self.session,
|
||||
request.url,
|
||||
share_in_flight=False,
|
||||
params=request.params,
|
||||
headers=request.headers,
|
||||
timeout=request.timeout
|
||||
|
||||
@@ -19,6 +19,7 @@ import json
|
||||
from typing import Dict, Any, Optional, List, cast
|
||||
|
||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||
from src.common.fetch_service import fetch_get, share_connection_pool
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +60,13 @@ class BaseOddsManager:
|
||||
# Deliberately no retry adapter, unlike api_helper: retries multiply
|
||||
# request_timeout, which is set to 5s precisely to stay inside that
|
||||
# budget. One try, then the cooldown below.
|
||||
#
|
||||
# Every scoreboard league manager builds one of these, so the session
|
||||
# mounts the fetch service's shared no-retry adapter: the same single
|
||||
# try, over one connection pool per host for all of them instead of
|
||||
# one pool per instance.
|
||||
self.session = requests.Session()
|
||||
share_connection_pool(self.session, max_retries=0)
|
||||
self.session.headers.update(DEFAULT_HTTP_HEADERS)
|
||||
|
||||
# Configuration with defaults
|
||||
@@ -168,7 +175,7 @@ class BaseOddsManager:
|
||||
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
||||
self.logger.debug(f"Requesting odds from URL: {url}")
|
||||
|
||||
response = self.session.get(url, timeout=self.request_timeout)
|
||||
response = fetch_get(self.session, url, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
raw_data = response.json()
|
||||
|
||||
|
||||
+21
-1
@@ -27,6 +27,7 @@ Rules for the package:
|
||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 |
|
||||
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
||||
| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.0 |
|
||||
| [`fetch_service`](#fetch_service) | Pooled, merged, budgeted and counted HTTP for core fetch paths | No, core-internal (reached through `api_helper` and `espn_dates`) | n/a |
|
||||
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
||||
| [`frame_timing`](#frame_timing) | Timing of every presented frame, stall watchdog | No, core-internal | n/a |
|
||||
| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | 3.5.0 |
|
||||
@@ -108,7 +109,9 @@ and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
|
||||
splits a range into month and day requests ESPN accepts and merges the
|
||||
results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
|
||||
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
||||
Scoreboard plugins also bundle a copy for older cores.
|
||||
Every request goes through [`fetch_service`](#fetch_service), the chunks
|
||||
counted against the plugin that asked. Scoreboard plugins also bundle a copy
|
||||
for older cores.
|
||||
|
||||
### favorite_team_check
|
||||
|
||||
@@ -121,6 +124,23 @@ says the league has nothing on yet; `reset()` re-arms it after a config edit.
|
||||
Diagnostics only: every failure is swallowed. Scoreboard plugins also bundle
|
||||
a copy for older cores.
|
||||
|
||||
### fetch_service
|
||||
|
||||
[`fetch_service.py`](fetch_service.py). Core-internal for now. Every core
|
||||
fetch path -- `APIHelper.get`/`post`, `espn_dates` (so every scoreboard's
|
||||
ESPN scoreboard fetch and `SportsFetchMixin`), `BackgroundDataService` and
|
||||
`BaseOddsManager` -- calls `fetch_get(session, url, ...)` instead of
|
||||
`session.get(url, ...)`. Same arguments, return value and exceptions; on top
|
||||
it shares one connection pool per host per retry policy
|
||||
(`share_connection_pool`), merges identical GETs in flight, applies per-host
|
||||
token buckets (`fetch_service.rate_limits` in config.json; ESPN gets 20/s,
|
||||
burst 200), revalidates with server-sent `ETag`/`Last-Modified` and counts
|
||||
requests per plugin and per host. The display publishes the counters
|
||||
(`FetchStatsPublisher`) for `GET /api/v3/plugins/fetch-stats`. Which plugin
|
||||
made a request comes from `plugin_scope()`, set by the plugin executor, or
|
||||
else from the plugin directory on the stack. See
|
||||
[docs/PLUGIN_API_REFERENCE.md](../../docs/PLUGIN_API_REFERENCE.md#fetching-data).
|
||||
|
||||
### font_layout
|
||||
|
||||
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
|
||||
|
||||
@@ -11,10 +11,10 @@ import time
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from src.common.espn_dates import ESPN_MAX_LIMIT
|
||||
from src.common.fetch_service import fetch_get, fetch_post, share_connection_pool
|
||||
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -45,7 +45,11 @@ class APIHelper:
|
||||
|
||||
- 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`.
|
||||
: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.
|
||||
@@ -81,9 +85,10 @@ class APIHelper:
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=["GET", "HEAD", "OPTIONS"]
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
self.session.mount("https://", adapter)
|
||||
self.session.mount("http://", adapter)
|
||||
# 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'})
|
||||
|
||||
@@ -128,7 +133,8 @@ class APIHelper:
|
||||
request_headers.update(headers)
|
||||
|
||||
# Make request
|
||||
response = self.session.get(
|
||||
response = fetch_get(
|
||||
self.session,
|
||||
url,
|
||||
params=params,
|
||||
headers=request_headers,
|
||||
@@ -255,7 +261,8 @@ class APIHelper:
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
|
||||
response = self.session.post(
|
||||
response = fetch_post(
|
||||
self.session,
|
||||
url,
|
||||
data=data,
|
||||
json=json_data,
|
||||
|
||||
@@ -32,6 +32,7 @@ scoreboards ask every 30 seconds. After that the range is tried again, so the
|
||||
workaround retires itself if ESPN reverts.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -47,6 +48,21 @@ except ImportError:
|
||||
def response_json(response: Any) -> Any:
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
# The core fetch service: counts, per-host budget, merging of identical
|
||||
# requests. Same call, same result and errors as ``session.get``.
|
||||
from src.common.fetch_service import fetch_get, pinned_caller
|
||||
except ImportError:
|
||||
# Bundled copies on cores without it call the session directly.
|
||||
import contextlib
|
||||
|
||||
def fetch_get(session: Any, url: str, *, share_in_flight: bool = True,
|
||||
**kwargs: Any) -> Any:
|
||||
return session.get(url, **kwargs)
|
||||
|
||||
def pinned_caller() -> Any:
|
||||
return contextlib.nullcontext()
|
||||
|
||||
# Above this, ESPN returns a truncated list instead of an error. See module
|
||||
# docstring: 500 is the largest value measured to return complete data.
|
||||
ESPN_MAX_LIMIT = 500
|
||||
@@ -195,7 +211,8 @@ def _fetch_one_chunk(
|
||||
logged and swallowed here rather than raised to the gather below.
|
||||
"""
|
||||
try:
|
||||
response = session.get(
|
||||
response = fetch_get(
|
||||
session,
|
||||
url,
|
||||
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
||||
headers=headers,
|
||||
@@ -220,6 +237,10 @@ def _fetch_chunks(
|
||||
callers keep ``chunks`` order from the returned list -- but it does mean
|
||||
the session is shared across threads, which is why this only ever issues
|
||||
GETs and never touches session state.
|
||||
|
||||
Each chunk runs in a copy of the caller's context, with the caller pinned
|
||||
into it, so the fetch service counts the chunks against the plugin that
|
||||
asked for the range rather than against the core.
|
||||
"""
|
||||
if not chunks:
|
||||
return []
|
||||
@@ -229,10 +250,15 @@ def _fetch_chunks(
|
||||
if len(chunks) == 1:
|
||||
return [fetch(chunks[0])]
|
||||
workers = min(ESPN_CHUNK_WORKERS, len(chunks))
|
||||
with pinned_caller():
|
||||
# One copy per chunk: a Context cannot be entered by two threads.
|
||||
contexts = [contextvars.copy_context() for _ in chunks]
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=workers, thread_name_prefix="espn-chunk",
|
||||
) as pool:
|
||||
return list(pool.map(fetch, chunks))
|
||||
futures = [pool.submit(context.run, fetch, chunk)
|
||||
for context, chunk in zip(contexts, chunks)]
|
||||
return [future.result() for future in futures]
|
||||
|
||||
|
||||
def fetch_espn_date_chunks(
|
||||
@@ -363,7 +389,7 @@ def fetch_espn_scoreboard(
|
||||
# real error to log, without spending the chunks a second time.
|
||||
chunks_tried = True
|
||||
|
||||
response = session.get(url, params=params, headers=headers, timeout=timeout)
|
||||
response = fetch_get(session, url, params=params, headers=headers, timeout=timeout)
|
||||
if is_range and response.status_code == 400 and not chunks_tried:
|
||||
_note_range_rejected()
|
||||
if logger:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ CORE_CONFIG_KEYS = frozenset({
|
||||
'display',
|
||||
'sync',
|
||||
'plugin_system',
|
||||
'fetch_service',
|
||||
# Older or optional core sections still found in existing config files.
|
||||
'logging',
|
||||
'network',
|
||||
|
||||
@@ -132,6 +132,13 @@ class DisplayController:
|
||||
# The web interface's /api/v3/errors/* read what this publishes.
|
||||
from src.error_aggregator import start_error_snapshot_publisher
|
||||
start_error_snapshot_publisher(self.cache_manager)
|
||||
# Host budgets and the other fetch_service settings, before any plugin
|
||||
# fetches; the web UI's fetch statistics read what the publisher
|
||||
# writes (src/common/fetch_service.py).
|
||||
from src.common.fetch_service import (
|
||||
configure_fetch_service, start_fetch_stats_publisher)
|
||||
configure_fetch_service(self.config.get('fetch_service'))
|
||||
self._fetch_stats_publisher = start_fetch_stats_publisher(self.cache_manager)
|
||||
logger.info("Config loaded in %.3f seconds (hot-reload: %s)", time.time() - start_time, enable_hot_reload)
|
||||
|
||||
# Validate startup configuration. Errors are logged, not fatal. The
|
||||
@@ -3819,6 +3826,9 @@ class DisplayController:
|
||||
read stale values after the user saves settings via the web UI.
|
||||
"""
|
||||
self.config = new_config
|
||||
# A no-op unless the fetch_service section itself changed.
|
||||
from src.common.fetch_service import configure_fetch_service
|
||||
configure_fetch_service(new_config.get('fetch_service'))
|
||||
self._normal_brightness = (
|
||||
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
)
|
||||
@@ -3886,6 +3896,11 @@ class DisplayController:
|
||||
logger.warning("Error shutting down config service: %s", e)
|
||||
if getattr(self, '_font_usage_publisher', None) is not None:
|
||||
self._font_usage_publisher.stop()
|
||||
if getattr(self, '_fetch_stats_publisher', None) is not None:
|
||||
try:
|
||||
self._fetch_stats_publisher.stop()
|
||||
except Exception as e:
|
||||
logger.warning("Error stopping the fetch statistics publisher: %s", e)
|
||||
# Publishes "stopped", so the web UI stops reporting what was loaded.
|
||||
if getattr(self, '_plugin_runtime_publisher', None) is not None:
|
||||
try:
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any, Dict, Optional, Callable
|
||||
from threading import Thread
|
||||
import logging
|
||||
|
||||
from src.common.fetch_service import plugin_scope
|
||||
from src.exceptions import PluginError
|
||||
from src.logging_config import get_logger
|
||||
from src.error_aggregator import record_error
|
||||
@@ -83,7 +84,10 @@ class PluginExecutor:
|
||||
|
||||
def target():
|
||||
try:
|
||||
result_container['value'] = operation()
|
||||
# Fetches made by the operation (and by threads the core
|
||||
# starts from it) are counted against this plugin.
|
||||
with plugin_scope(plugin_id):
|
||||
result_container['value'] = operation()
|
||||
result_container['completed'] = True
|
||||
except Exception as e:
|
||||
result_container['exception'] = e
|
||||
|
||||
@@ -32,6 +32,7 @@ from src.plugin_system.schema_manager import (
|
||||
from src.plugin_system.plugin_dirs import (
|
||||
ManifestStatus, PluginDirectoryIndex, resolve_plugin_dir,
|
||||
)
|
||||
from src.common.fetch_service import plugin_scope, register_plugin_directory
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_plugin_dir_mode
|
||||
@@ -423,6 +424,11 @@ class PluginManager:
|
||||
# Update mapping if found via search
|
||||
if plugin_id not in self.plugin_directories:
|
||||
self.plugin_directories[plugin_id] = plugin_dir
|
||||
|
||||
# Code under this directory is this plugin's: the fetch service
|
||||
# counts a request against it even from a thread the plugin
|
||||
# started itself (src/common/fetch_service.py, caller identity).
|
||||
register_plugin_directory(plugin_id, plugin_dir)
|
||||
|
||||
# Get plugin config
|
||||
if self.config_manager:
|
||||
@@ -462,18 +468,20 @@ class PluginManager:
|
||||
config = dict(config)
|
||||
config['enabled'] = True
|
||||
|
||||
# Use PluginLoader to load plugin
|
||||
plugin_instance, _module = self.plugin_loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
display_manager=self.display_manager,
|
||||
cache_manager=self.cache_manager,
|
||||
plugin_manager=self,
|
||||
install_deps=True,
|
||||
plugins_dir=self.plugins_dir,
|
||||
)
|
||||
# Use PluginLoader to load plugin. Fetches the constructor makes
|
||||
# count against the plugin.
|
||||
with plugin_scope(plugin_id):
|
||||
plugin_instance, _module = self.plugin_loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
display_manager=self.display_manager,
|
||||
cache_manager=self.cache_manager,
|
||||
plugin_manager=self,
|
||||
install_deps=True,
|
||||
plugins_dir=self.plugins_dir,
|
||||
)
|
||||
|
||||
# Register plugin-shipped fonts with the FontManager (if any).
|
||||
# Plugin manifests can declare a "fonts" block that ships custom
|
||||
@@ -527,7 +535,8 @@ class PluginManager:
|
||||
# Call on_enable if plugin is enabled
|
||||
if hasattr(plugin_instance, 'on_enable'):
|
||||
try:
|
||||
plugin_instance.on_enable()
|
||||
with plugin_scope(plugin_id):
|
||||
plugin_instance.on_enable()
|
||||
except Exception:
|
||||
# Undo the registration above before the outer
|
||||
# handler marks it ERROR: left in self.plugins, the
|
||||
|
||||
Vendored
+9
@@ -467,6 +467,15 @@
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/fetch-stats",
|
||||
"api_v3.get_fetch_stats",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/health",
|
||||
"api_v3.get_plugin_health",
|
||||
|
||||
@@ -0,0 +1,878 @@
|
||||
"""The core fetch service: merging, host budgets, conditional GET, counters.
|
||||
|
||||
No network: every request goes to a fake transport -- a real
|
||||
``requests.Session`` subclass whose ``get`` answers from a handler -- so the
|
||||
service sees real ``requests.Response`` objects, real header merging and real
|
||||
adapters, and nothing leaves the machine. Clocks and sleeps are injected.
|
||||
|
||||
What callers already rely on (return values, exceptions, retries) is pinned
|
||||
by the existing suites, which run unchanged through the service:
|
||||
test_api_helper.py, test_background_data_service*.py,
|
||||
test_background_fetch_dedupe.py, test_base_odds_manager.py,
|
||||
test_odds_request_budget.py, test_espn_dates.py and test_sports_fetch.py.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from src.common import fetch_service as fs
|
||||
from src.common.fetch_service import (
|
||||
FetchService,
|
||||
FetchStatsPublisher,
|
||||
TokenBucket,
|
||||
current_plugin_id,
|
||||
plugin_scope,
|
||||
read_fetch_stats,
|
||||
register_plugin_directory,
|
||||
unregister_plugin_directory,
|
||||
)
|
||||
|
||||
|
||||
# --- fakes -----------------------------------------------------------------------
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self, start=1000.0):
|
||||
self.t = start
|
||||
self.sleeps = []
|
||||
|
||||
def now(self):
|
||||
return self.t
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.sleeps.append(seconds)
|
||||
self.t += seconds
|
||||
|
||||
def advance(self, seconds):
|
||||
self.t += seconds
|
||||
|
||||
|
||||
def make_response(status=200, body=b'{"ok": 1}', headers=None, url="https://api.test/x"):
|
||||
response = requests.Response()
|
||||
response.status_code = status
|
||||
response._content = body
|
||||
response.headers = CaseInsensitiveDict(headers or {})
|
||||
response.url = url
|
||||
response.encoding = "utf-8"
|
||||
response.reason = "OK" if status < 400 else "Error"
|
||||
return response
|
||||
|
||||
|
||||
class FakeSession(requests.Session):
|
||||
"""A Session whose get() answers from ``handler(url, kwargs)``."""
|
||||
|
||||
def __init__(self, handler=None, gate=None):
|
||||
super().__init__()
|
||||
self.handler = handler or (lambda url, kwargs: make_response(url=url))
|
||||
self.gate = gate
|
||||
self.calls = []
|
||||
self.started = threading.Event()
|
||||
self._calls_lock = threading.Lock()
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
with self._calls_lock:
|
||||
self.calls.append((url, kwargs))
|
||||
self.started.set()
|
||||
if self.gate is not None:
|
||||
assert self.gate.wait(5), "test gate never opened"
|
||||
return self.handler(url, kwargs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock():
|
||||
return FakeClock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(clock):
|
||||
return FetchService({"rate_limits": {}}, clock=clock.now, sleep=clock.sleep)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def global_service(monkeypatch, clock):
|
||||
"""A fresh process-wide service, for code that calls fetch_get()."""
|
||||
svc = FetchService({"rate_limits": {}}, clock=clock.now, sleep=clock.sleep)
|
||||
monkeypatch.setattr(fs, "_service", svc)
|
||||
return svc
|
||||
|
||||
|
||||
def _counters(svc, plugin=None, host=None):
|
||||
snap = svc.snapshot()
|
||||
if plugin is not None:
|
||||
return snap["plugins"].get(plugin, {})
|
||||
if host is not None:
|
||||
return snap["hosts"].get(host, {})
|
||||
return snap["totals"]
|
||||
|
||||
|
||||
# --- the call itself is unchanged -----------------------------------------------------
|
||||
|
||||
class TestPassThrough:
|
||||
|
||||
def test_session_get_sees_exactly_the_callers_arguments(self, service):
|
||||
session = FakeSession()
|
||||
response = service.get(session, "https://api.test/x", params={"a": 1},
|
||||
headers={"X-Y": "z"}, timeout=7)
|
||||
assert session.calls == [("https://api.test/x",
|
||||
{"params": {"a": 1}, "headers": {"X-Y": "z"}, "timeout": 7})]
|
||||
assert response.json() == {"ok": 1}
|
||||
|
||||
def test_no_kwargs_the_caller_did_not_pass(self, service):
|
||||
session = FakeSession()
|
||||
service.get(session, "https://api.test/x", timeout=5)
|
||||
assert session.calls[0][1] == {"timeout": 5}
|
||||
|
||||
def test_the_transport_exception_reaches_the_caller_unchanged(self, service):
|
||||
boom = requests.ConnectionError("down")
|
||||
|
||||
def handler(url, kwargs):
|
||||
raise boom
|
||||
|
||||
with pytest.raises(requests.ConnectionError) as caught:
|
||||
service.get(FakeSession(handler), "https://api.test/x")
|
||||
assert caught.value is boom
|
||||
|
||||
def test_an_http_error_response_is_returned_not_raised(self, service):
|
||||
session = FakeSession(lambda url, kw: make_response(503, b"busy"))
|
||||
response = service.get(session, "https://api.test/x")
|
||||
assert response.status_code == 503
|
||||
with pytest.raises(requests.HTTPError):
|
||||
response.raise_for_status()
|
||||
|
||||
def test_disabled_is_a_plain_session_get(self, clock):
|
||||
svc = FetchService({"enabled": False, "rate_limits": {"api.test": {"per_second": 1, "burst": 1}}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession()
|
||||
for _ in range(3):
|
||||
svc.get(session, "https://api.test/x")
|
||||
assert len(session.calls) == 3
|
||||
assert clock.sleeps == []
|
||||
assert _counters(svc)["requests"] == 0
|
||||
|
||||
def test_a_test_double_session_still_works(self, service):
|
||||
from unittest.mock import MagicMock
|
||||
session = MagicMock()
|
||||
session.get.return_value.json.return_value = {"a": 1}
|
||||
assert service.get(session, "https://api.test/x", timeout=3).json() == {"a": 1}
|
||||
session.get.assert_called_once_with("https://api.test/x", timeout=3)
|
||||
|
||||
def test_session_none_uses_the_pooled_session_for_the_host(self, service, monkeypatch):
|
||||
seen = []
|
||||
monkeypatch.setattr(requests.Session, "get",
|
||||
lambda self, url, **kw: seen.append(self) or make_response())
|
||||
service.get(None, "https://a.test/1")
|
||||
service.get(None, "https://a.test/2")
|
||||
service.get(None, "https://b.test/1")
|
||||
assert seen[0] is seen[1] is service.session_for("https://a.test/")
|
||||
assert seen[2] is not seen[0]
|
||||
|
||||
|
||||
# --- single-flight ------------------------------------------------------------------------
|
||||
|
||||
def _wait_for_waiters(svc, count, timeout=5):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
with svc._lock:
|
||||
flights = list(svc._inflight.values())
|
||||
if flights and flights[0].waiters >= count:
|
||||
return
|
||||
time.sleep(0.005)
|
||||
raise AssertionError(f"only {flights[0].waiters if flights else 0} of {count} callers joined")
|
||||
|
||||
|
||||
class TestSingleFlight:
|
||||
|
||||
def test_concurrent_identical_gets_go_out_once(self, service):
|
||||
gate = threading.Event()
|
||||
session = FakeSession(gate=gate)
|
||||
results = []
|
||||
|
||||
def call():
|
||||
results.append(service.get(session, "https://api.test/x",
|
||||
params={"d": "1"}, timeout=5))
|
||||
|
||||
threads = [threading.Thread(target=call) for _ in range(5)]
|
||||
threads[0].start()
|
||||
assert session.started.wait(5)
|
||||
for t in threads[1:]:
|
||||
t.start()
|
||||
_wait_for_waiters(service, 4)
|
||||
gate.set()
|
||||
for t in threads:
|
||||
t.join(5)
|
||||
|
||||
assert len(session.calls) == 1
|
||||
assert len(results) == 5
|
||||
assert all(r.json() == {"ok": 1} for r in results)
|
||||
# Each caller gets its own Response object to mutate.
|
||||
assert len({id(r) for r in results}) == 5
|
||||
totals = _counters(service)
|
||||
assert totals["requests"] == 1
|
||||
assert totals["merged"] == 4
|
||||
|
||||
def test_merged_callers_get_the_leaders_exception(self, service):
|
||||
gate = threading.Event()
|
||||
|
||||
def handler(url, kwargs):
|
||||
raise requests.Timeout("slow")
|
||||
|
||||
session = FakeSession(handler, gate=gate)
|
||||
errors = []
|
||||
|
||||
def call():
|
||||
try:
|
||||
service.get(session, "https://api.test/x", timeout=5)
|
||||
except requests.Timeout as err:
|
||||
errors.append(err)
|
||||
|
||||
threads = [threading.Thread(target=call) for _ in range(3)]
|
||||
threads[0].start()
|
||||
assert session.started.wait(5)
|
||||
for t in threads[1:]:
|
||||
t.start()
|
||||
_wait_for_waiters(service, 2)
|
||||
gate.set()
|
||||
for t in threads:
|
||||
t.join(5)
|
||||
|
||||
assert len(session.calls) == 1
|
||||
assert len(errors) == 3
|
||||
totals = _counters(service)
|
||||
assert totals["errors"] == 1 and totals["merged"] == 2
|
||||
|
||||
@pytest.mark.parametrize("second", [
|
||||
{"params": {"d": "2"}}, # another query
|
||||
{"params": {"d": "1"}, "timeout": 9}, # another timeout
|
||||
{"params": {"d": "1"}, "headers": {"Accept": "text/plain"}}, # another representation
|
||||
])
|
||||
def test_requests_that_could_answer_differently_are_not_merged(self, service, second):
|
||||
gate = threading.Event()
|
||||
session = FakeSession(gate=gate)
|
||||
first = threading.Thread(target=lambda: service.get(
|
||||
session, "https://api.test/x", params={"d": "1"}, timeout=5))
|
||||
first.start()
|
||||
assert session.started.wait(5)
|
||||
other = threading.Thread(target=lambda: service.get(
|
||||
session, "https://api.test/x", **{"timeout": 5, **second}))
|
||||
other.start()
|
||||
deadline = time.monotonic() + 5
|
||||
while len(session.calls) < 2 and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
gate.set()
|
||||
first.join(5)
|
||||
other.join(5)
|
||||
assert len(session.calls) == 2
|
||||
assert _counters(service)["merged"] == 0
|
||||
|
||||
def test_different_retry_policies_are_not_merged(self, service):
|
||||
gate = threading.Event()
|
||||
retrying = FakeSession(gate=gate)
|
||||
retrying.mount("https://", requests.adapters.HTTPAdapter(max_retries=Retry(total=3)))
|
||||
plain = FakeSession(gate=gate)
|
||||
a = threading.Thread(target=lambda: service.get(retrying, "https://api.test/x"))
|
||||
a.start()
|
||||
assert retrying.started.wait(5)
|
||||
b = threading.Thread(target=lambda: service.get(plain, "https://api.test/x"))
|
||||
b.start()
|
||||
assert plain.started.wait(5)
|
||||
gate.set()
|
||||
a.join(5)
|
||||
b.join(5)
|
||||
assert len(retrying.calls) == len(plain.calls) == 1
|
||||
|
||||
def test_sessions_with_the_same_policy_and_headers_share_a_flight(self, service):
|
||||
gate = threading.Event()
|
||||
one, two = FakeSession(gate=gate), FakeSession(gate=gate)
|
||||
a = threading.Thread(target=lambda: service.get(one, "https://api.test/x", timeout=5))
|
||||
a.start()
|
||||
assert one.started.wait(5)
|
||||
b = threading.Thread(target=lambda: service.get(two, "https://api.test/x", timeout=5))
|
||||
b.start()
|
||||
_wait_for_waiters(service, 1)
|
||||
gate.set()
|
||||
a.join(5)
|
||||
b.join(5)
|
||||
assert len(one.calls) == 1 and two.calls == []
|
||||
|
||||
def test_a_session_with_cookies_only_merges_with_itself(self, service):
|
||||
gate = threading.Event()
|
||||
cookied, plain = FakeSession(gate=gate), FakeSession(gate=gate)
|
||||
cookied.cookies.set("sid", "secret")
|
||||
a = threading.Thread(target=lambda: service.get(cookied, "https://api.test/x"))
|
||||
a.start()
|
||||
assert cookied.started.wait(5)
|
||||
b = threading.Thread(target=lambda: service.get(plain, "https://api.test/x"))
|
||||
b.start()
|
||||
assert plain.started.wait(5)
|
||||
gate.set()
|
||||
a.join(5)
|
||||
b.join(5)
|
||||
assert len(cookied.calls) == len(plain.calls) == 1
|
||||
|
||||
def test_sequential_identical_gets_each_go_out(self, service):
|
||||
session = FakeSession()
|
||||
service.get(session, "https://api.test/x")
|
||||
service.get(session, "https://api.test/x")
|
||||
assert len(session.calls) == 2
|
||||
|
||||
def test_streamed_requests_are_never_merged(self, service):
|
||||
gate = threading.Event()
|
||||
session = FakeSession(gate=gate)
|
||||
a = threading.Thread(target=lambda: service.get(session, "https://api.test/x", stream=True))
|
||||
a.start()
|
||||
assert session.started.wait(5)
|
||||
b = threading.Thread(target=lambda: service.get(session, "https://api.test/x", stream=True))
|
||||
b.start()
|
||||
deadline = time.monotonic() + 5
|
||||
while len(session.calls) < 2 and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
gate.set()
|
||||
a.join(5)
|
||||
b.join(5)
|
||||
assert len(session.calls) == 2
|
||||
|
||||
|
||||
# --- token buckets ---------------------------------------------------------------------------
|
||||
|
||||
class TestTokenBucket:
|
||||
|
||||
def test_burst_then_one_token_per_interval(self, clock):
|
||||
bucket = TokenBucket(per_second=2, burst=3, clock=clock.now)
|
||||
assert [bucket.reserve(10)[0] for _ in range(3)] == [0.0, 0.0, 0.0]
|
||||
assert bucket.reserve(10) == (0.5, False)
|
||||
assert bucket.reserve(10) == (1.0, False)
|
||||
|
||||
def test_tokens_refill_with_time_up_to_the_burst(self, clock):
|
||||
bucket = TokenBucket(per_second=2, burst=3, clock=clock.now)
|
||||
for _ in range(3):
|
||||
bucket.reserve(10)
|
||||
clock.advance(100)
|
||||
assert [bucket.reserve(10)[0] for _ in range(3)] == [0.0, 0.0, 0.0]
|
||||
assert bucket.reserve(10)[0] == 0.5
|
||||
|
||||
def test_a_wait_is_capped_at_max_wait(self, clock):
|
||||
bucket = TokenBucket(per_second=1, burst=1, clock=clock.now)
|
||||
bucket.reserve(0.5)
|
||||
assert bucket.reserve(0.5) == (0.5, True)
|
||||
# The debt never runs further than max_wait either.
|
||||
assert bucket.reserve(0.5) == (0.5, True)
|
||||
clock.advance(10)
|
||||
assert bucket.reserve(0.5) == (0.0, False)
|
||||
|
||||
|
||||
class TestHostBudgets:
|
||||
|
||||
def test_requests_past_the_budget_wait(self, clock):
|
||||
svc = FetchService({"rate_limits": {"api.test": {"per_second": 1, "burst": 2}},
|
||||
"max_wait_seconds": 10}, clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession()
|
||||
for _ in range(4):
|
||||
svc.get(session, "https://api.test/x")
|
||||
assert clock.sleeps == [1.0, 1.0]
|
||||
host = _counters(svc, host="api.test")
|
||||
assert host["throttled"] == 2 and host["wait_seconds"] == 2.0
|
||||
assert host["requests"] == 4
|
||||
|
||||
def test_other_hosts_are_not_throttled(self, clock):
|
||||
svc = FetchService({"rate_limits": {"api.test": {"per_second": 1, "burst": 1}}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession()
|
||||
for _ in range(5):
|
||||
svc.get(session, "https://elsewhere.test/x")
|
||||
assert clock.sleeps == []
|
||||
|
||||
def test_each_host_has_its_own_bucket(self, clock):
|
||||
svc = FetchService({"rate_limits": {"*.espn.com": {"per_second": 1, "burst": 1}},
|
||||
"max_wait_seconds": 10}, clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession()
|
||||
svc.get(session, "https://site.api.espn.com/a")
|
||||
svc.get(session, "https://sports.core.api.espn.com/a")
|
||||
assert clock.sleeps == []
|
||||
svc.get(session, "https://site.api.espn.com/a")
|
||||
assert clock.sleeps == [1.0]
|
||||
|
||||
def test_wildcard_matches_the_bare_domain_and_subdomains_only(self, clock):
|
||||
svc = FetchService({"rate_limits": {"*.espn.com": {"per_second": 5, "burst": 9}}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
assert svc._limit_for("espn.com") == (5.0, 9.0)
|
||||
assert svc._limit_for("site.api.espn.com") == (5.0, 9.0)
|
||||
assert svc._limit_for("notespn.com") is None
|
||||
|
||||
def test_the_default_budget_covers_espn_and_a_cold_season_burst(self, clock):
|
||||
svc = FetchService(clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession()
|
||||
for _ in range(200):
|
||||
svc.get(session, "https://site.api.espn.com/x")
|
||||
assert clock.sleeps == []
|
||||
svc.get(session, "https://site.api.espn.com/x")
|
||||
assert clock.sleeps == [pytest.approx(0.05)]
|
||||
svc.get(session, "https://api.example.org/x")
|
||||
assert len(clock.sleeps) == 1
|
||||
|
||||
def test_zero_per_second_removes_a_budget(self, clock):
|
||||
svc = FetchService({"rate_limits": {"*.espn.com": {"per_second": 0, "burst": 1}}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession()
|
||||
for _ in range(5):
|
||||
svc.get(session, "https://site.api.espn.com/x")
|
||||
assert clock.sleeps == []
|
||||
|
||||
def test_a_merged_caller_spends_no_token(self, clock):
|
||||
svc = FetchService({"rate_limits": {"api.test": {"per_second": 1, "burst": 1}},
|
||||
"max_wait_seconds": 10}, clock=clock.now, sleep=clock.sleep)
|
||||
gate = threading.Event()
|
||||
session = FakeSession(gate=gate)
|
||||
a = threading.Thread(target=lambda: svc.get(session, "https://api.test/x"))
|
||||
a.start()
|
||||
assert session.started.wait(5)
|
||||
b = threading.Thread(target=lambda: svc.get(session, "https://api.test/x"))
|
||||
b.start()
|
||||
_wait_for_waiters(svc, 1)
|
||||
gate.set()
|
||||
a.join(5)
|
||||
b.join(5)
|
||||
assert clock.sleeps == []
|
||||
|
||||
|
||||
# --- conditional GET ----------------------------------------------------------------------------
|
||||
|
||||
class Versioned:
|
||||
"""A server with one resource and an ETag, honouring If-None-Match."""
|
||||
|
||||
def __init__(self, validator="etag"):
|
||||
self.version = 1
|
||||
self.validator = validator
|
||||
self.seen = []
|
||||
|
||||
def body(self):
|
||||
return json.dumps({"version": self.version}).encode()
|
||||
|
||||
def tag(self):
|
||||
if self.validator == "etag":
|
||||
return {"ETag": f'"v{self.version}"'}
|
||||
return {"Last-Modified": f"Thu, 01 Oct 2026 00:00:0{self.version} GMT"}
|
||||
|
||||
def __call__(self, url, kwargs):
|
||||
headers = CaseInsensitiveDict(kwargs.get("headers") or {})
|
||||
self.seen.append(dict(headers))
|
||||
current = self.tag()
|
||||
if (headers.get("If-None-Match") == current.get("ETag") and "ETag" in current) or \
|
||||
(headers.get("If-Modified-Since") == current.get("Last-Modified")
|
||||
and "Last-Modified" in current):
|
||||
return make_response(304, b"", headers={**current, "Date": "now"}, url=url)
|
||||
return make_response(200, self.body(),
|
||||
headers={**current, "Content-Type": "application/json"}, url=url)
|
||||
|
||||
|
||||
class TestConditionalGet:
|
||||
|
||||
@pytest.mark.parametrize("validator,header", [("etag", "If-None-Match"),
|
||||
("last-modified", "If-Modified-Since")])
|
||||
def test_a_304_returns_the_stored_body_as_a_200(self, service, validator, header):
|
||||
server = Versioned(validator)
|
||||
session = FakeSession(server)
|
||||
first = service.get(session, "https://api.test/x", timeout=5)
|
||||
second = service.get(session, "https://api.test/x", timeout=5)
|
||||
|
||||
assert header not in server.seen[0]
|
||||
assert header in server.seen[1]
|
||||
assert second.status_code == 200
|
||||
assert second.json() == first.json() == {"version": 1}
|
||||
assert second.headers["Content-Type"] == "application/json"
|
||||
second.raise_for_status()
|
||||
totals = _counters(service)
|
||||
assert totals["requests"] == 2
|
||||
assert totals["not_modified"] == 1
|
||||
assert totals["bytes"] == len(server.body()) # the 304 carried none
|
||||
|
||||
def test_a_changed_resource_is_fetched_and_stored_again(self, service):
|
||||
server = Versioned()
|
||||
session = FakeSession(server)
|
||||
service.get(session, "https://api.test/x")
|
||||
server.version = 2
|
||||
changed = service.get(session, "https://api.test/x")
|
||||
assert changed.json() == {"version": 2}
|
||||
again = service.get(session, "https://api.test/x")
|
||||
assert again.json() == {"version": 2}
|
||||
assert server.seen[2]["If-None-Match"] == '"v2"'
|
||||
|
||||
def test_no_validators_no_conditional_request(self, service):
|
||||
session = FakeSession() # answers 200 with no ETag/Last-Modified
|
||||
service.get(session, "https://api.test/x", headers={"A": "1"})
|
||||
service.get(session, "https://api.test/x", headers={"A": "1"})
|
||||
assert session.calls[1][1] == {"headers": {"A": "1"}}
|
||||
assert service.snapshot()["validators"]["entries"] == 0
|
||||
|
||||
def test_a_200_without_validators_drops_the_stored_one(self, service):
|
||||
server = Versioned()
|
||||
session = FakeSession(server)
|
||||
service.get(session, "https://api.test/x")
|
||||
session.handler = lambda url, kw: make_response(200, b'{"new": 1}', url=url)
|
||||
service.get(session, "https://api.test/x")
|
||||
assert service.snapshot()["validators"]["entries"] == 0
|
||||
|
||||
def test_a_callers_own_conditional_request_is_left_alone(self, service):
|
||||
server = Versioned()
|
||||
session = FakeSession(server)
|
||||
service.get(session, "https://api.test/x")
|
||||
raw = service.get(session, "https://api.test/x", headers={"If-None-Match": '"v1"'})
|
||||
assert raw.status_code == 304
|
||||
|
||||
def test_validators_are_per_representation(self, service):
|
||||
server = Versioned()
|
||||
session = FakeSession(server)
|
||||
service.get(session, "https://api.test/x", params={"d": "1"})
|
||||
service.get(session, "https://api.test/x", params={"d": "2"})
|
||||
assert "If-None-Match" not in server.seen[1]
|
||||
|
||||
def test_a_body_too_big_for_the_store_is_not_kept(self, clock):
|
||||
svc = FetchService({"rate_limits": {}, "validator_store": {"max_entry_bytes": 4}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
server = Versioned()
|
||||
session = FakeSession(server)
|
||||
svc.get(session, "https://api.test/x")
|
||||
svc.get(session, "https://api.test/x")
|
||||
assert "If-None-Match" not in server.seen[1]
|
||||
|
||||
def test_the_store_evicts_least_recently_used_past_its_budget(self, clock):
|
||||
svc = FetchService({"rate_limits": {}, "validator_store": {"max_entries": 2}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession(Versioned())
|
||||
for path in ("a", "b", "c"):
|
||||
svc.get(session, f"https://api.test/{path}")
|
||||
assert svc.snapshot()["validators"]["entries"] == 2
|
||||
|
||||
def test_off_switch(self, clock):
|
||||
svc = FetchService({"rate_limits": {}, "conditional_get": False},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
server = Versioned()
|
||||
session = FakeSession(server)
|
||||
svc.get(session, "https://api.test/x")
|
||||
svc.get(session, "https://api.test/x")
|
||||
assert "If-None-Match" not in server.seen[1]
|
||||
|
||||
|
||||
# --- counters and caller identity ----------------------------------------------------------------
|
||||
|
||||
class TestCounters:
|
||||
|
||||
def test_per_plugin_and_per_host(self, service):
|
||||
session = FakeSession()
|
||||
with plugin_scope("weather"):
|
||||
service.get(session, "https://api.weather.test/now")
|
||||
service.get(session, "https://api.weather.test/later")
|
||||
service.get(session, "https://site.api.espn.com/x")
|
||||
snap = service.snapshot()
|
||||
assert snap["plugins"]["weather"]["requests"] == 2
|
||||
assert snap["plugins"]["weather"]["hosts"] == {"api.weather.test": 2}
|
||||
assert snap["plugins"]["core"]["requests"] == 1
|
||||
assert snap["hosts"]["api.weather.test"]["requests"] == 2
|
||||
assert snap["hosts"]["site.api.espn.com"]["requests"] == 1
|
||||
assert snap["totals"]["bytes"] == 3 * len(b'{"ok": 1}')
|
||||
|
||||
def test_errors_and_http_errors(self, service):
|
||||
def handler(url, kwargs):
|
||||
if url.endswith("/down"):
|
||||
raise requests.ConnectionError("down")
|
||||
return make_response(404, b"nope", url=url)
|
||||
|
||||
session = FakeSession(handler)
|
||||
with pytest.raises(requests.ConnectionError):
|
||||
service.get(session, "https://api.test/down")
|
||||
service.get(session, "https://api.test/missing")
|
||||
totals = _counters(service)
|
||||
assert totals["requests"] == 2
|
||||
assert totals["errors"] == 1
|
||||
assert totals["http_errors"] == 1
|
||||
|
||||
def test_every_change_bumps_the_change_count(self, service):
|
||||
before = service.change_count
|
||||
service.get(FakeSession(), "https://api.test/x")
|
||||
assert service.change_count > before
|
||||
|
||||
def test_post_is_counted_and_never_merged(self, service):
|
||||
class PostSession(FakeSession):
|
||||
def post(self, url, **kwargs):
|
||||
self.calls.append((url, kwargs))
|
||||
return make_response(201, b"{}", url=url)
|
||||
|
||||
session = PostSession()
|
||||
with plugin_scope("poster"):
|
||||
response = service.post(session, "https://api.test/x", json={"a": 1})
|
||||
assert response.status_code == 201
|
||||
assert session.calls == [("https://api.test/x", {"json": {"a": 1}})]
|
||||
assert _counters(service, plugin="poster")["requests"] == 1
|
||||
|
||||
|
||||
class TestCallerIdentity:
|
||||
|
||||
def test_scope_wins_and_nests(self):
|
||||
assert current_plugin_id() is None
|
||||
with plugin_scope("outer"):
|
||||
assert current_plugin_id() == "outer"
|
||||
with plugin_scope("inner"):
|
||||
assert current_plugin_id() == "inner"
|
||||
with plugin_scope(None):
|
||||
assert current_plugin_id() == "outer"
|
||||
assert current_plugin_id() is None
|
||||
|
||||
def test_a_plugins_own_thread_is_named_by_its_source_directory(self, tmp_path, service):
|
||||
plugin_dir = tmp_path / "my-plugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "fetcher.py").write_text(
|
||||
"import threading\n"
|
||||
"def fetch_in_thread(service, session, url):\n"
|
||||
" t = threading.Thread(target=lambda: service.get(session, url))\n"
|
||||
" t.start()\n"
|
||||
" t.join(5)\n",
|
||||
encoding="utf-8")
|
||||
spec = importlib.util.spec_from_file_location("_fs_test_fetcher", plugin_dir / "fetcher.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
register_plugin_directory("my-plugin", plugin_dir)
|
||||
try:
|
||||
module.fetch_in_thread(service, FakeSession(), "https://api.test/x")
|
||||
finally:
|
||||
unregister_plugin_directory("my-plugin")
|
||||
assert _counters(service, plugin="my-plugin")["requests"] == 1
|
||||
assert "core" not in service.snapshot()["plugins"]
|
||||
|
||||
def test_the_executor_scopes_a_plugin_operation(self):
|
||||
from src.plugin_system.plugin_executor import PluginExecutor
|
||||
seen = PluginExecutor().execute_with_timeout(current_plugin_id, plugin_id="clock")
|
||||
assert seen == "clock"
|
||||
|
||||
def test_background_fetches_count_against_the_submitter(self, global_service):
|
||||
from unittest.mock import MagicMock
|
||||
from src.background_data_service import BackgroundDataService
|
||||
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
bds = BackgroundDataService(cache, max_workers=1, request_timeout=5)
|
||||
bds.session = FakeSession(lambda url, kw: make_response(body=b'{"events": []}', url=url))
|
||||
try:
|
||||
with plugin_scope("football-scoreboard"):
|
||||
request_id = bds.submit_fetch_request(
|
||||
"nfl", 2026, "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard",
|
||||
cache_key="fs_test_nfl", params={"dates": "2026"})
|
||||
deadline = time.monotonic() + 5
|
||||
while not bds.is_request_complete(request_id) and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert bds.get_result(request_id).success
|
||||
finally:
|
||||
bds.shutdown(wait=True)
|
||||
assert _counters(global_service, plugin="football-scoreboard")["requests"] == 1
|
||||
|
||||
def test_espn_chunks_on_worker_threads_count_against_the_caller(self, global_service):
|
||||
from src.common.espn_dates import espn_date_chunks, fetch_espn_date_chunks, parse_espn_date_range
|
||||
|
||||
session = FakeSession(lambda url, kw: make_response(body=b'{"events": []}', url=url))
|
||||
dates = "20260801-20261015"
|
||||
with plugin_scope("baseball-scoreboard"):
|
||||
fetch_espn_date_chunks(session, "https://site.api.espn.com/s/scoreboard",
|
||||
params={"dates": dates})
|
||||
chunks = len(espn_date_chunks(*parse_espn_date_range(dates)))
|
||||
assert chunks > 1
|
||||
assert len(session.calls) == chunks
|
||||
assert _counters(global_service, plugin="baseball-scoreboard")["requests"] == chunks
|
||||
assert "core" not in global_service.snapshot()["plugins"]
|
||||
|
||||
def test_api_helper_goes_through_the_service(self, global_service):
|
||||
from src.common.api_helper import APIHelper
|
||||
|
||||
helper = APIHelper()
|
||||
helper.set_rate_limit(0)
|
||||
helper.session = FakeSession(lambda url, kw: make_response(body=b'{"a": 1}', url=url))
|
||||
with plugin_scope("nfl-draft"):
|
||||
assert helper.get("https://api.test/x") == {"a": 1}
|
||||
assert _counters(global_service, plugin="nfl-draft")["requests"] == 1
|
||||
|
||||
def test_odds_go_through_the_service(self, global_service):
|
||||
from unittest.mock import MagicMock
|
||||
from src.base_odds_manager import BaseOddsManager
|
||||
|
||||
cache = MagicMock()
|
||||
cache.get_with_auto_strategy.return_value = None
|
||||
manager = BaseOddsManager(cache)
|
||||
manager.session = FakeSession(
|
||||
lambda url, kw: make_response(body=b'{"count": 0, "items": []}', url=url))
|
||||
with plugin_scope("odds-ticker"):
|
||||
assert manager.get_odds("football", "nfl", "401") is None
|
||||
assert manager.session.calls[0][1] == {"timeout": manager.request_timeout}
|
||||
assert _counters(global_service, plugin="odds-ticker")["requests"] == 1
|
||||
|
||||
|
||||
# --- pooling -------------------------------------------------------------------------------------------
|
||||
|
||||
class TestConnectionPool:
|
||||
|
||||
def test_core_sessions_with_one_policy_share_one_adapter(self, global_service):
|
||||
from unittest.mock import MagicMock
|
||||
from src.background_data_service import BackgroundDataService
|
||||
from src.base_odds_manager import BaseOddsManager
|
||||
|
||||
odds_a = BaseOddsManager(MagicMock()).session.get_adapter("https://x.test")
|
||||
odds_b = BaseOddsManager(MagicMock()).session.get_adapter("https://x.test")
|
||||
bds = BackgroundDataService(MagicMock(), max_workers=1)
|
||||
try:
|
||||
assert odds_a is odds_b is bds.session.get_adapter("https://x.test")
|
||||
assert odds_a.max_retries.total == 0
|
||||
finally:
|
||||
bds.shutdown(wait=False)
|
||||
|
||||
def test_a_different_retry_policy_gets_its_own_adapter(self, global_service):
|
||||
from src.common.api_helper import APIHelper
|
||||
helper_adapter = APIHelper().session.get_adapter("https://x.test")
|
||||
assert helper_adapter is APIHelper().session.get_adapter("https://x.test")
|
||||
assert helper_adapter is not global_service.shared_adapter(0)
|
||||
assert helper_adapter.max_retries.total == 3
|
||||
assert helper_adapter.max_retries.status_forcelist == [429, 500, 502, 503, 504]
|
||||
assert APIHelper(max_retries=1).session.get_adapter("https://x.test") is not helper_adapter
|
||||
|
||||
def test_the_pooled_session_keeps_no_cookies(self, service):
|
||||
import http.client
|
||||
import io
|
||||
from types import SimpleNamespace
|
||||
from requests.cookies import extract_cookies_to_jar
|
||||
|
||||
def offer_cookie(session):
|
||||
msg = http.client.parse_headers(io.BytesIO(b"Set-Cookie: sid=1; Path=/" + b"\r\n" * 2))
|
||||
raw = SimpleNamespace(_original_response=SimpleNamespace(msg=msg))
|
||||
request = requests.Request("GET", "https://api.test/").prepare()
|
||||
extract_cookies_to_jar(session.cookies, request, raw)
|
||||
return len(session.cookies)
|
||||
|
||||
assert offer_cookie(requests.Session()) == 1 # what a private Session does
|
||||
assert offer_cookie(service.session_for("https://api.test/")) == 0
|
||||
|
||||
|
||||
# --- configuration ----------------------------------------------------------------------------------
|
||||
|
||||
class TestConfigure:
|
||||
|
||||
def test_reapplying_the_same_section_keeps_the_validator_store(self, clock):
|
||||
config = {"rate_limits": {}}
|
||||
svc = FetchService(config, clock=clock.now, sleep=clock.sleep)
|
||||
svc.get(FakeSession(Versioned()), "https://api.test/x")
|
||||
svc.configure(dict(config))
|
||||
assert svc.snapshot()["validators"]["entries"] == 1
|
||||
svc.configure({"rate_limits": {"api.test": {"per_second": 1}}})
|
||||
assert svc.snapshot()["validators"]["entries"] == 0
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
"nonsense",
|
||||
{"rate_limits": "nonsense"},
|
||||
{"rate_limits": {"api.test": "fast"}},
|
||||
{"rate_limits": {"api.test": {"per_second": -1}}},
|
||||
{"max_wait_seconds": "long"},
|
||||
])
|
||||
def test_bad_values_fall_back_without_raising(self, clock, bad):
|
||||
svc = FetchService(bad, clock=clock.now, sleep=clock.sleep)
|
||||
assert svc.describe_config()["max_wait_seconds"] == 2.0
|
||||
svc.get(FakeSession(), "https://api.test/x")
|
||||
|
||||
def test_the_template_section_is_what_the_code_defaults_to(self):
|
||||
import os
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
with open(os.path.join(root, "config", "config.template.json"), encoding="utf-8") as fh:
|
||||
template = json.load(fh)["fetch_service"]
|
||||
for key, value in template.items():
|
||||
assert fs.DEFAULT_CONFIG[key] == value
|
||||
|
||||
|
||||
# --- publishing and reading -----------------------------------------------------------------------------
|
||||
|
||||
class SharedCache:
|
||||
def __init__(self):
|
||||
self.entries = {}
|
||||
self.writes = 0
|
||||
|
||||
def get(self, key, max_age=None, memory_ttl=None):
|
||||
return self.entries.get(key)
|
||||
|
||||
def set(self, key, value, *args, **kwargs):
|
||||
self.writes += 1
|
||||
self.entries[key] = json.loads(json.dumps(value))
|
||||
|
||||
|
||||
class TestPublisher:
|
||||
|
||||
def _publisher(self, service, clock, cache):
|
||||
return FetchStatsPublisher(cache, service, clock=clock.now, wall_clock=lambda: 5000.0)
|
||||
|
||||
def test_on_change_at_most_once_a_minute(self, service, clock):
|
||||
cache = SharedCache()
|
||||
publisher = self._publisher(service, clock, cache)
|
||||
assert publisher.tick() is True # first publish
|
||||
assert publisher.tick() is False # nothing changed
|
||||
service.get(FakeSession(), "https://api.test/x")
|
||||
clock.advance(30)
|
||||
assert publisher.tick() is False # changed, but too soon
|
||||
clock.advance(30)
|
||||
assert publisher.tick() is True
|
||||
assert cache.writes == 2
|
||||
snap = cache.entries[fs.FETCH_STATS_KEY]
|
||||
assert snap["running"] is True
|
||||
assert snap["totals"]["requests"] == 1
|
||||
|
||||
def test_heartbeat_when_nothing_changes(self, service, clock):
|
||||
cache = SharedCache()
|
||||
publisher = self._publisher(service, clock, cache)
|
||||
publisher.tick()
|
||||
clock.advance(fs.REFRESH_INTERVAL - 1)
|
||||
assert publisher.tick() is False
|
||||
clock.advance(1)
|
||||
assert publisher.tick() is True
|
||||
|
||||
def test_stop_publishes_stopped(self, service, clock):
|
||||
cache = SharedCache()
|
||||
publisher = self._publisher(service, clock, cache)
|
||||
publisher.stop()
|
||||
assert cache.entries[fs.FETCH_STATS_KEY]["running"] is False
|
||||
|
||||
def test_a_failing_cache_never_raises(self, service, clock):
|
||||
class Broken(SharedCache):
|
||||
def set(self, *a, **k):
|
||||
raise OSError("disk full")
|
||||
|
||||
assert self._publisher(service, clock, Broken()).tick() is False
|
||||
|
||||
def test_reader_statuses(self, service, clock):
|
||||
cache = SharedCache()
|
||||
assert read_fetch_stats(cache)["status"] == "unknown"
|
||||
assert read_fetch_stats(None)["status"] == "unknown"
|
||||
publisher = self._publisher(service, clock, cache)
|
||||
publisher.tick()
|
||||
assert read_fetch_stats(cache, now=5010.0)["status"] == "live"
|
||||
assert read_fetch_stats(cache, now=5000.0 + fs.STALE_AFTER + 1)["status"] == "stale"
|
||||
publisher.stop()
|
||||
view = read_fetch_stats(cache, now=5010.0)
|
||||
assert view["status"] == "stopped"
|
||||
assert view["data"]["totals"]["requests"] == 0
|
||||
|
||||
|
||||
def test_the_web_route_returns_the_published_counters(clock):
|
||||
from test._api_v3_test_helpers import build_app
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
|
||||
svc = FetchService({"rate_limits": {}}, clock=clock.now, sleep=clock.sleep)
|
||||
with plugin_scope("weather"):
|
||||
svc.get(FakeSession(), "https://api.test/x")
|
||||
cache = SharedCache()
|
||||
FetchStatsPublisher(cache, svc, wall_clock=time.time).tick()
|
||||
|
||||
original = getattr(module.api_v3, "cache_manager", None)
|
||||
module.api_v3.cache_manager = cache
|
||||
try:
|
||||
body = build_app(module.api_v3).test_client().get("/api/v3/plugins/fetch-stats").get_json()
|
||||
finally:
|
||||
module.api_v3.cache_manager = original
|
||||
assert body["status"] == "success"
|
||||
assert body["data"]["status"] == "live"
|
||||
assert body["data"]["data"]["plugins"]["weather"]["requests"] == 1
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Plugin health, resource metrics and resource limits.
|
||||
"""Plugin health, resource metrics, fetch statistics and resource limits.
|
||||
|
||||
The display process records health and metrics to the shared on-disk cache;
|
||||
these routes read (and reset) that published state through a tracker and a
|
||||
@@ -193,6 +193,24 @@ def reset_plugin_metrics(plugin_id):
|
||||
})
|
||||
|
||||
|
||||
@api_v3.route('/plugins/fetch-stats', methods=['GET'])
|
||||
def get_fetch_stats():
|
||||
"""Network requests per plugin and per host, as the display counts them.
|
||||
|
||||
Read-only. The display's fetch service (src/common/fetch_service.py)
|
||||
publishes its counters to the shared cache at most once a minute when
|
||||
they change; this returns that snapshot judged for staleness:
|
||||
``data.status`` is ``live``, ``stale``, ``stopped`` or ``unknown``, and
|
||||
``data.data`` the snapshot (None when unknown). Counters are cumulative
|
||||
since the display started.
|
||||
"""
|
||||
from src.common.fetch_service import read_fetch_stats
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': read_fetch_stats(getattr(api_v3, 'cache_manager', None)),
|
||||
})
|
||||
|
||||
|
||||
@api_v3.route('/plugins/limits/<plugin_id>', methods=['GET', 'POST'])
|
||||
def manage_plugin_limits(plugin_id):
|
||||
"""Get or set resource limits for a plugin.
|
||||
|
||||
Reference in New Issue
Block a user