diff --git a/README.md b/README.md index 246bbc9f..e0f79f72 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,25 @@ Both endpoints are public (no `X-Internal-Secret` required). A coverage test (`backend/tests/test_openapi_coverage.py`) asserts every registered route is documented, so the spec never drifts from the code. +### `/predict` response cache + +`POST /predict` is fronted by an in-process, content-addressed response cache +(`backend/predict_cache.py`). The cache key is `sha256` of the *normalised* +input text (via `utils/text_normalizer`) combined with the prediction options +and the live model version, so: + +* repeated identical requests skip the full inference pipeline and return the + cached body, and +* a `POST /reload-model` hot-swap bumps the serving version and transparently + invalidates every prior entry โ€” a stale model can never serve a cached answer. + +Each response carries an `X-Cache: HIT|MISS` header. Send `Cache-Control: +no-cache` or `?fresh=1` to bypass the lookup and force a fresh computation. +Aggregate counters (hits, misses, size, evictions, hit rate โ€” never any cached +content) are exposed at the public `GET /cache-stats`. The cache is bounded by +TTL and LRU eviction and configured via `PREDICT_CACHE_ENABLED`, +`PREDICT_CACHE_MAX_SIZE` and `PREDICT_CACHE_TTL_SECONDS`. + --- ## ๐Ÿงพ Model Registry & Provenance diff --git a/backend/api.py b/backend/api.py index 182e275b..39a89577 100644 --- a/backend/api.py +++ b/backend/api.py @@ -115,6 +115,7 @@ "/openapi.json", "/docs", "/metrics", + "/cache-stats", "/model-info", } @@ -1001,6 +1002,17 @@ def rate_limit_status(): ) +@app.route("/cache-stats", methods=["GET"]) +@validate_request +def cache_stats(): + """Observability for the /predict response cache (issue #1008). + + Public (see PUBLIC_PATHS): exposes only aggregate counters (hits, misses, + size, evictions, hit rate) -- never any cached message content. + """ + return jsonify(predict_cache.CACHE.stats()) + + # ============================================ # API DOCUMENTATION (OpenAPI 3.0 + Swagger UI) # ============================================ @@ -1119,6 +1131,20 @@ def make_prediction_response( return response +def _cache_bypass_requested(): + """True when the caller opts out of the /predict cache for this request. + + Honours the standard ``Cache-Control: no-cache`` request directive and a + convenience ``?fresh=1`` query flag, so a client can force a fresh + computation (e.g. to confirm a just-reloaded model) without disabling the + cache process-wide. + """ + if request.args.get("fresh") == "1": + return True + cache_control = request.headers.get("Cache-Control", "") + return "no-cache" in cache_control.lower() + + @app.route("/predict", methods=["POST"]) @validate_request @validate_internal_request @@ -1205,6 +1231,25 @@ def predict(): detected_language = "en" translated = False + # Content-addressed response cache (issue #1008). Key on the normalised + # input plus the live serving version, so a model hot-swap (which bumps + # serving_state.version) transparently invalidates every prior entry and + # a stale model can never serve a cached answer. Look up before any of + # the expensive translation / analysis / inference work below; refresh + # the entry on a miss (and on an explicit bypass, so a forced-fresh + # request also repopulates the cache). + cache_options = {"type": input_type} + cache_key = predict_cache.make_cache_key( + normalizer.normalize(text), serving.version, cache_options + ) + cache_bypass = _cache_bypass_requested() + if not cache_bypass: + cached_body = predict_cache.CACHE.get(cache_key) + if cached_body is not None: + response = jsonify(cached_body) + response.headers["X-Cache"] = "HIT" + return response + if input_type != "url" and text.strip(): try: from langdetect import detect, DetectorFactory @@ -1328,7 +1373,10 @@ def predict(): metrics.record_prediction(result=final_output, input_type=input_type) - return jsonify(response_data) + predict_cache.CACHE.set(cache_key, response_data) + response = jsonify(response_data) + response.headers["X-Cache"] = "MISS" + return response except Exception as e: request_id = getattr(g, "request_id", "unknown") diff --git a/backend/openapi_spec.py b/backend/openapi_spec.py index 90ad4bb8..60f94578 100644 --- a/backend/openapi_spec.py +++ b/backend/openapi_spec.py @@ -146,6 +146,28 @@ def _core_paths(): "summary": "Classify a message or URL", "operationId": "predict", "tags": ["Prediction"], + "description": ( + "Responses are served from a content-addressed cache keyed " + "by the normalised input, the prediction options and the " + "live model version; a model hot-swap invalidates it. Send " + "`Cache-Control: no-cache` or `?fresh=1` to bypass the " + "lookup and force a fresh computation. The `X-Cache` " + "response header reports whether the body was served from " + "cache." + ), + "parameters": [ + { + "name": "fresh", + "in": "query", + "required": False, + "schema": {"type": "string", "enum": ["1"]}, + "description": ( + "Set to '1' to bypass the response cache and force " + "a fresh computation (equivalent to sending " + "Cache-Control: no-cache)." + ), + }, + ], "requestBody": { "required": True, "content": { @@ -155,11 +177,20 @@ def _core_paths(): }, }, "responses": { - "200": _json_response( - "Prediction result with confidence, URL risk and " - "explanation details.", - {"$ref": "#/components/schemas/PredictionResponse"}, - ), + "200": { + "description": ( + "Prediction result with confidence, URL risk and " + "explanation details." + ), + "headers": {"X-Cache": _x_cache_header()}, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PredictionResponse" + } + } + }, + }, "400": _error_response("Missing/invalid text or body."), "403": _error_response("Missing or invalid internal secret."), "500": _error_response("Inference error."), @@ -574,6 +605,21 @@ def _extended_paths(): }, } }, + "/cache-stats": { + "get": { + "summary": "/predict response cache statistics", + "operationId": "getCacheStats", + "tags": ["System"], + "security": [], + "responses": { + "200": _json_response( + "Aggregate hit/miss/size counters for the /predict " + "response cache (never any cached content).", + {"$ref": "#/components/schemas/CacheStats"}, + ) + }, + } + }, "/api/wordcloud": { "get": { "summary": "Spam word frequencies for the word cloud", @@ -965,6 +1011,20 @@ def _extended_paths(): def _extended_schemas(): return { + "CacheStats": { + "type": "object", + "description": "Aggregate counters for the /predict response cache.", + "properties": { + "enabled": {"type": "boolean"}, + "hits": {"type": "integer"}, + "misses": {"type": "integer"}, + "size": {"type": "integer"}, + "max_size": {"type": "integer"}, + "ttl_seconds": {"type": "number"}, + "evictions": {"type": "integer"}, + "hit_rate": { + "type": "number", + "description": "hits / (hits + misses), rounded to 4 dp.", "ArtifactInfo": { "type": "object", "description": "Content fingerprint of one model artifact.", @@ -1091,6 +1151,16 @@ def _error_response(description): return _json_response(description, _ERROR) +def _x_cache_header(): + return { + "description": ( + "Whether the body was served from the /predict response cache: " + "HIT (cached) or MISS (freshly computed)." + ), + "schema": {"type": "string", "enum": ["HIT", "MISS"]}, + } + + def _query_param(name, description, required=False, schema=None): return { "name": name, diff --git a/backend/predict_cache.py b/backend/predict_cache.py new file mode 100644 index 00000000..5d5f04a5 --- /dev/null +++ b/backend/predict_cache.py @@ -0,0 +1,237 @@ +"""Content-addressed response cache for the /predict hot path (issue #1008). + +Repeated /predict calls with the same input re-run the full pipeline (language +detection, optional translation, domain analysis, vectorisation, inference and +explainability) even when the answer is byte-for-byte identical. This module +memoises the prediction payload keyed by the *content* of the request rather +than by any client-supplied identifier. + +Two properties make the cache safe to sit in front of a live model: + +* **Version namespacing.** The key folds in ``serving_state``'s monotonically + increasing ``version`` (see :mod:`serving_state`). A ``POST /reload-model`` + hot-swap bumps that version, so every prior entry becomes unreachable and the + first post-swap request is a miss -- a stale model can never serve a cached + answer. Old entries age out naturally via TTL/LRU; no explicit purge needed. +* **Content addressing.** The key is ``sha256`` of the *normalised* input text + (via ``utils.text_normalizer``) combined with the prediction options, so + homoglyph/whitespace-obfuscated variants that normalise to the same text + share a cache slot and an attacker cannot trivially blow the cache with + cosmetic variations. + +The cache combines TTL expiry (entries older than ``ttl_seconds`` are treated +as absent) with LRU eviction (the least-recently-used entry is dropped once the +cache is full). It is safe for concurrent use from Flask's threaded server: all +state transitions happen under a single lock, and values are deep-copied on both +``set`` and ``get`` so a caller mutating a returned payload can never poison a +cached entry. + +>>> cache = PredictCache(max_size=8, ttl_seconds=100.0) +>>> key = make_cache_key("free money now", version=1, options={"type": "message"}) +>>> cache.get(key) is None +True +>>> cache.set(key, {"result": "spam"}) +>>> cache.get(key) +{'result': 'spam'} +>>> cache.stats()["hits"], cache.stats()["misses"] +(1, 2) +""" + +from __future__ import annotations + +from collections import OrderedDict +import copy +from dataclasses import dataclass +import hashlib +import json +import os +import threading +import time +from typing import Any + +__all__ = [ + "PredictCache", + "CACHE", + "make_cache_key", + "get", + "set", + "stats", + "clear", +] + +# Sane defaults for a single ML API process. 512 distinct recent inputs at a +# few KB each is a small, bounded footprint; a 5-minute TTL keeps entries fresh +# enough that model/data drift between reloads is not a concern in practice. +DEFAULT_MAX_SIZE = 512 +DEFAULT_TTL_SECONDS = 300.0 + + +def _now() -> float: + """Monotonic clock used for TTL accounting (patched in tests).""" + return time.monotonic() + + +@dataclass(slots=True) +class _Entry: + value: Any + expires_at: float + + +class PredictCache: + """Thread-safe bounded cache combining TTL expiry with LRU eviction. + + ``max_size`` caps the number of live entries; inserting into a full cache + evicts the least-recently-used one. ``ttl_seconds`` bounds an entry's age -- + a read past the TTL is a miss and drops the entry. ``enabled=False`` turns + the cache into a transparent no-op (every ``get`` misses, every ``set`` is + dropped, and no hit/miss is counted) so it can be switched off by config + without touching call sites. + """ + + def __init__( + self, + *, + max_size: int = DEFAULT_MAX_SIZE, + ttl_seconds: float = DEFAULT_TTL_SECONDS, + enabled: bool = True, + ) -> None: + self._max_size = max(0, int(max_size)) + self._ttl_seconds = float(ttl_seconds) + self._enabled = bool(enabled) + self._lock = threading.Lock() + self._entries: "OrderedDict[str, _Entry]" = OrderedDict() + self._hits = 0 + self._misses = 0 + self._evictions = 0 + + def get(self, key: str) -> Any | None: + """Return a deep copy of the cached payload, or ``None`` on miss. + + A present-but-expired entry is treated as a miss and evicted so the TTL + is enforced lazily on read without a background sweeper. + """ + if not self._enabled: + return None + with self._lock: + entry = self._entries.get(key) + if entry is None: + self._misses += 1 + return None + if entry.expires_at <= _now(): + del self._entries[key] + self._misses += 1 + return None + self._entries.move_to_end(key) + self._hits += 1 + return copy.deepcopy(entry.value) + + def set(self, key: str, value: Any) -> None: + """Store ``value`` under ``key`` with a fresh TTL, evicting LRU as needed.""" + if not self._enabled or self._max_size == 0: + return + with self._lock: + self._entries[key] = _Entry( + value=copy.deepcopy(value), expires_at=_now() + self._ttl_seconds + ) + self._entries.move_to_end(key) + while len(self._entries) > self._max_size: + self._entries.popitem(last=False) + self._evictions += 1 + + def stats(self) -> dict[str, Any]: + """Snapshot of counters: hits, misses, size, evictions and hit_rate.""" + with self._lock: + total = self._hits + self._misses + return { + "enabled": self._enabled, + "hits": self._hits, + "misses": self._misses, + "size": len(self._entries), + "max_size": self._max_size, + "ttl_seconds": self._ttl_seconds, + "evictions": self._evictions, + "hit_rate": round(self._hits / total, 4) if total else 0.0, + } + + def clear(self) -> None: + """Drop all entries and reset the hit/miss/eviction counters.""" + with self._lock: + self._entries.clear() + self._hits = 0 + self._misses = 0 + self._evictions = 0 + + +def make_cache_key( + normalized_text: str, version: int, options: dict[str, Any] | None = None +) -> str: + """Build a stable cache key from normalised text, model version and options. + + The text is hashed (``sha256``) rather than embedded so the key length is + bounded and message content never lives in the key. ``version`` namespaces + the key by the live serving state, so a model hot-swap transparently + invalidates every prior entry. ``options`` (e.g. the ``type`` selector that + routes to the URL vs text classifier) are serialised deterministically so + two calls differing only in options don't collide. + """ + text_hash = hashlib.sha256((normalized_text or "").encode("utf-8")).hexdigest() + options_repr = json.dumps( + options or {}, sort_keys=True, separators=(",", ":"), default=str + ) + return f"v{version}:{text_hash}:{options_repr}" + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None or not raw.strip(): + return default + try: + return int(raw.strip()) + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + raw = os.getenv(name) + if raw is None or not raw.strip(): + return default + try: + return float(raw.strip()) + except ValueError: + return default + + +def _env_flag(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in ("1", "true", "yes", "on") + + +def _build_cache_from_env() -> PredictCache: + return PredictCache( + max_size=_env_int("PREDICT_CACHE_MAX_SIZE", DEFAULT_MAX_SIZE), + ttl_seconds=_env_float("PREDICT_CACHE_TTL_SECONDS", DEFAULT_TTL_SECONDS), + enabled=_env_flag("PREDICT_CACHE_ENABLED", True), + ) + + +# Process-wide cache the /predict handler reads through. Built once from the +# environment at import; tests construct their own PredictCache instances. +CACHE = _build_cache_from_env() + + +def get(key: str) -> Any | None: + return CACHE.get(key) + + +def set(key: str, value: Any) -> None: # noqa: A001 - mirrors the cache method name + CACHE.set(key, value) + + +def stats() -> dict[str, Any]: + return CACHE.stats() + + +def clear() -> None: + CACHE.clear() diff --git a/backend/tests/test_predict_cache.py b/backend/tests/test_predict_cache.py new file mode 100644 index 00000000..ced3cabd --- /dev/null +++ b/backend/tests/test_predict_cache.py @@ -0,0 +1,310 @@ +"""Tests for the content-addressed /predict response cache (issue #1008). + +Two layers of coverage: + +* Unit tests drive :class:`predict_cache.PredictCache` and + :func:`predict_cache.make_cache_key` directly (no Flask app, no ML deps), with + an injected clock. They pin hit/miss accounting, TTL expiry, LRU eviction, + version-namespaced invalidation and deep-copy isolation. +* Endpoint tests exercise the wiring in ``/predict`` -- the ``X-Cache`` header, + the ``Cache-Control``/``?fresh=1`` bypass, and cache invalidation when a model + hot-swap bumps the serving version. They skip cleanly if ``api`` can't be + imported in this environment (e.g. optional ML dependencies missing). +""" + +from itertools import count +import os +from pathlib import Path +import sys + +import pytest + +BASE_DIR = Path(__file__).resolve().parents[2] +BACKEND_DIR = BASE_DIR / "backend" + +os.environ.setdefault("MODEL_PATH", str(BASE_DIR / "linear_svm_model.pkl")) +os.environ.setdefault("VECTORIZER_PATH", str(BACKEND_DIR / "tfidf_vectorizer.pkl")) +os.environ.setdefault("LABEL_ENCODER_PATH", str(BASE_DIR / "label_encoder.pkl")) +os.environ.setdefault("URL_MODEL_PATH", str(BACKEND_DIR / "url_detector.pkl")) +os.environ.setdefault("URL_VECTORIZER_PATH", str(BACKEND_DIR / "url_vectorizer.pkl")) + +sys.path.insert(0, str(BACKEND_DIR)) + +from conftest import TEST_INTERNAL_SECRET # noqa: E402 +import predict_cache # noqa: E402 + +VALID_SECRET = {"X-Internal-Secret": TEST_INTERNAL_SECRET} + + +# --------------------------------------------------------------------------- +# Unit tests: PredictCache + make_cache_key (no app import required) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def clock(monkeypatch): + """A controllable stand-in for predict_cache._now, starting at t=1000.""" + state = {"t": 1000.0} + monkeypatch.setattr(predict_cache, "_now", lambda: state["t"]) + return state + + +def test_miss_then_hit_counts_and_returns_value(): + cache = predict_cache.PredictCache(max_size=8, ttl_seconds=100.0) + key = predict_cache.make_cache_key("free money", 1, {"type": "message"}) + + assert cache.get(key) is None # miss + cache.set(key, {"result": "spam"}) + assert cache.get(key) == {"result": "spam"} # hit + + stats = cache.stats() + assert stats["hits"] == 1 + assert stats["misses"] == 1 + assert stats["size"] == 1 + assert stats["hit_rate"] == 0.5 + + +def test_entry_expires_after_ttl(clock): + cache = predict_cache.PredictCache(max_size=8, ttl_seconds=100.0) + key = predict_cache.make_cache_key("hello", 1, {"type": "message"}) + + cache.set(key, {"result": "ham"}) # stored at t=1000, expires at 1100 + clock["t"] = 1050.0 + assert cache.get(key) == {"result": "ham"} # still fresh + clock["t"] = 1101.0 + assert cache.get(key) is None # expired -> miss and evicted + + stats = cache.stats() + assert stats["hits"] == 1 + assert stats["misses"] == 1 + assert stats["size"] == 0 + + +def test_version_bump_invalidates_prior_entry(): + cache = predict_cache.PredictCache(max_size=8, ttl_seconds=100.0) + options = {"type": "message"} + key_v1 = predict_cache.make_cache_key("win a prize", 1, options) + key_v2 = predict_cache.make_cache_key("win a prize", 2, options) + + cache.set(key_v1, {"result": "spam", "version": 1}) + + # A hot-swap bumps serving_state.version; the new-version key must miss so a + # stale model can never serve a cached answer -- but the old entry is still + # addressable under its own version until it ages out. + assert key_v1 != key_v2 + assert cache.get(key_v2) is None + assert cache.get(key_v1) == {"result": "spam", "version": 1} + + +def test_options_change_produces_distinct_key(): + as_message = predict_cache.make_cache_key("bit.ly/x", 1, {"type": "message"}) + as_url = predict_cache.make_cache_key("bit.ly/x", 1, {"type": "url"}) + assert as_message != as_url + + +def test_lru_eviction_respects_max_size(): + cache = predict_cache.PredictCache(max_size=2, ttl_seconds=100.0) + keys = { + name: predict_cache.make_cache_key(name, 1, {"type": "message"}) + for name in ("a", "b", "c") + } + + cache.set(keys["a"], {"v": "a"}) + cache.set(keys["b"], {"v": "b"}) + cache.get(keys["a"]) # touch a -> b becomes the LRU victim + cache.set(keys["c"], {"v": "c"}) # inserts c, evicts b + + stats = cache.stats() + assert stats["size"] == 2 + assert stats["evictions"] == 1 + assert cache.get(keys["b"]) is None # b was evicted + assert cache.get(keys["a"]) == {"v": "a"} + + +def test_values_are_deep_copied_on_set_and_get(): + cache = predict_cache.PredictCache(max_size=8, ttl_seconds=100.0) + key = predict_cache.make_cache_key("mutate me", 1, {"type": "message"}) + + payload = {"nested": {"score": 10}} + cache.set(key, payload) + payload["nested"]["score"] = 999 # mutating the source must not leak in + + fetched = cache.get(key) + assert fetched["nested"]["score"] == 10 + fetched["nested"]["score"] = -1 # mutating the copy must not poison the cache + assert cache.get(key)["nested"]["score"] == 10 + + +def test_disabled_cache_is_a_noop(): + cache = predict_cache.PredictCache(max_size=8, ttl_seconds=100.0, enabled=False) + key = predict_cache.make_cache_key("anything", 1, {"type": "message"}) + + cache.set(key, {"result": "spam"}) + assert cache.get(key) is None + + stats = cache.stats() + assert stats["enabled"] is False + assert stats["hits"] == 0 + assert stats["misses"] == 0 + assert stats["size"] == 0 + + +def test_clear_resets_entries_and_counters(): + cache = predict_cache.PredictCache(max_size=8, ttl_seconds=100.0) + key = predict_cache.make_cache_key("x", 1, {"type": "message"}) + cache.set(key, {"v": 1}) + cache.get(key) + + cache.clear() + + stats = cache.stats() + assert stats == { + "enabled": True, + "hits": 0, + "misses": 0, + "size": 0, + "max_size": 8, + "ttl_seconds": 100.0, + "evictions": 0, + "hit_rate": 0.0, + } + + +# --------------------------------------------------------------------------- +# Endpoint tests: /predict wiring (skipped if `api` cannot be imported) +# --------------------------------------------------------------------------- + + +class _FakeVectorizer: + def transform(self, texts): + return list(texts) + + +class _FakeModel: + """Returns a version-tagged label so a hot-swap yields a distinct body.""" + + def __init__(self, token): + self.token = token + + def predict(self, _vectorized): + return [f"spam-{self.token}"] + + +class _FakeLabelEncoder: + def inverse_transform(self, prediction): + return list(prediction) + + +class _FakeXAI: + def analyze(self, *_args, **_kwargs): + return {"reasons": []} + + def get_global_importance(self): + return [] + + +def _family(token): + return { + "model": _FakeModel(token), + "vectorizer": _FakeVectorizer(), + "label_encoder": _FakeLabelEncoder(), + "xai_service": _FakeXAI(), + } + + +def _make_loader(): + tokens = count(2) # initial install is token 1; first reload -> token 2 + + def loader(): + return _family(next(tokens)) + + return loader + + +@pytest.fixture +def api_module(): + try: + import api as _api # noqa: E402 + except Exception as exc: # pragma: no cover - env without ML deps/models + pytest.skip(f"api import unavailable: {exc}") + return _api + + +@pytest.fixture +def client(api_module): + api_module.app.config["TESTING"] = True + with api_module.app.test_client() as c: + yield c + + +@pytest.fixture +def fake_state(api_module): + """Point the process-wide serving state at fakes and start with a clean cache.""" + import serving_state + + original = serving_state.STATE + serving_state.init_state(loader=_make_loader(), **_family(1)) + predict_cache.CACHE.clear() + yield serving_state.STATE + serving_state.STATE = original + predict_cache.CACHE.clear() + + +def _predict(client, text, **kwargs): + headers = dict(VALID_SECRET) + headers.update(kwargs.pop("headers", {})) + return client.post("/predict", json={"text": text}, headers=headers, **kwargs) + + +def test_first_call_misses_then_repeat_hits(client, fake_state): + first = _predict(client, "buy cheap pills now") + assert first.status_code == 200 + assert first.headers.get("X-Cache") == "MISS" + + second = _predict(client, "buy cheap pills now") + assert second.status_code == 200 + assert second.headers.get("X-Cache") == "HIT" + # A hit must serve the identical body the miss computed. + assert second.get_json() == first.get_json() + + +def test_no_cache_header_bypasses_lookup(client, fake_state): + _predict(client, "limited offer") # populate + bypass = _predict(client, "limited offer", headers={"Cache-Control": "no-cache"}) + assert bypass.status_code == 200 + assert bypass.headers.get("X-Cache") == "MISS" + + +def test_fresh_query_param_bypasses_lookup(client, fake_state): + _predict(client, "limited offer") # populate + bypass = client.post( + "/predict?fresh=1", json={"text": "limited offer"}, headers=VALID_SECRET + ) + assert bypass.status_code == 200 + assert bypass.headers.get("X-Cache") == "MISS" + + +def test_version_bump_invalidates_and_recomputes(client, fake_state): + before = _predict(client, "identical text") + assert before.headers.get("X-Cache") == "MISS" + assert before.get_json()["prediction"] == "spam-1" + assert _predict(client, "identical text").headers.get("X-Cache") == "HIT" + + # Hot-swap the model; the version bump must invalidate the cached answer. + fake_state.reload() + + after = _predict(client, "identical text") + assert after.headers.get("X-Cache") == "MISS" + assert after.get_json()["prediction"] == "spam-2" + + +def test_cache_stats_endpoint_is_public_and_reports_counters(client, fake_state): + _predict(client, "count me") + _predict(client, "count me") # one miss, one hit + + res = client.get("/cache-stats") # no internal secret -> public + assert res.status_code == 200 + body = res.get_json() + assert body["hits"] >= 1 + assert body["misses"] >= 1 + assert "hit_rate" in body