API: edge-cacheable headers for global startup endpoints ("Gathering" speedup)
"Gathering the good news…" waits on the home's startup API calls, which were all DYNAMIC → a round-trip to the residential origin every load (the occasional 2-3s linger). These responses depend only on the URL, never the session, so they're safe to share at the edge: - /api/moods, /api/categories (static config) → public, s-maxage=900 - /api/lanes, /api/families (global, data-derived counts) → public, s-maxage=120 - /api/feed → public, s-maxage=45 ONLY when shareable (no following / prefs / exclude); the following feed (reads the session) and personal filters stay private, no-store. Hard personalization boundary, explicit per-endpoint (no blanket /api/* rule). Pairs with a Cloudflare cache rule (added separately) making these paths eligible. Tests assert the global endpoints are public+s-maxage and the feed boundary (default/topic public; following/prefs/exclude private). 227 pytest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+26
-4
@@ -45,6 +45,16 @@ from .lanes import build_lane_pool
|
||||
from .paywall import is_paywalled
|
||||
from .taxonomy import FAMILIES, FLAVORS, TOPICS
|
||||
|
||||
# Edge-cache directives for GLOBAL endpoints — responses that depend only on the
|
||||
# URL, never the session/user, so Cloudflare may safely serve one visitor's copy
|
||||
# to another (this is what makes "Gathering the good news…" resolve from the edge
|
||||
# instead of a round-trip to the residential origin). The personalization
|
||||
# boundary is hard: anything session- or filter-specific stays private/no-store.
|
||||
_EDGE_CONFIG = "public, max-age=0, s-maxage=900, stale-while-revalidate=600" # static config (moods/categories)
|
||||
_EDGE_DERIVED = "public, max-age=0, s-maxage=120, stale-while-revalidate=120" # global, data-derived (lanes/families)
|
||||
_EDGE_FEED = "public, max-age=0, s-maxage=45, stale-while-revalidate=30" # global feed (URL-keyed, shareable only)
|
||||
_PRIVATE = "private, no-store" # never share across users
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
|
||||
# Prefer the built SvelteKit site; fall back to the legacy single-page harness.
|
||||
@@ -1410,23 +1420,26 @@ def create_app() -> FastAPI:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/categories", response_model=CategoriesResponse)
|
||||
def categories() -> CategoriesResponse:
|
||||
def categories(response: Response) -> CategoriesResponse:
|
||||
response.headers["Cache-Control"] = _EDGE_CONFIG # static taxonomy, identical for everyone
|
||||
return CategoriesResponse(
|
||||
topics=[Category(key=k, description=v) for k, v in TOPICS.items()],
|
||||
flavors=[Category(key=k, description=v) for k, v in FLAVORS.items()],
|
||||
)
|
||||
|
||||
@app.get("/api/moods")
|
||||
def moods() -> list[dict]:
|
||||
def moods(response: Response) -> list[dict]:
|
||||
# The humane front door: each mood resolves to a filter preset the
|
||||
# client merges with the user's own Calm Filters.
|
||||
response.headers["Cache-Control"] = _EDGE_CONFIG # static presets, identical for everyone
|
||||
return MOODS
|
||||
|
||||
@app.get("/api/lanes")
|
||||
def lanes() -> dict:
|
||||
def lanes(response: Response) -> dict:
|
||||
# The customizable quick-access rail: 'today' is always pinned, and the
|
||||
# reader pins any subset of these moods / topics / Discovery tags. Live
|
||||
# counts let the client gate empty lanes and show volume.
|
||||
response.headers["Cache-Control"] = _EDGE_DERIVED # global counts, no per-user data
|
||||
with get_conn() as conn:
|
||||
tagc = queries.tag_counts(conn)
|
||||
topicc: dict[str, int] = {}
|
||||
@@ -1435,8 +1448,9 @@ def create_app() -> FastAPI:
|
||||
return build_lane_pool(topicc, tagc)
|
||||
|
||||
@app.get("/api/families")
|
||||
def families() -> list[dict]:
|
||||
def families(response: Response) -> list[dict]:
|
||||
# Grouping vocabulary organised into calm families for the Explore UI.
|
||||
response.headers["Cache-Control"] = _EDGE_DERIVED # global vocab + counts, no per-user data
|
||||
with get_conn() as conn:
|
||||
counts = queries.tag_counts(conn)
|
||||
return [
|
||||
@@ -1468,6 +1482,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.get("/api/feed", response_model=FeedResponse)
|
||||
def feed(
|
||||
response: Response,
|
||||
topic: str | None = Query(None),
|
||||
flavor: str | None = Query(None),
|
||||
accepted_only: bool = True,
|
||||
@@ -1481,6 +1496,13 @@ def create_app() -> FastAPI:
|
||||
following: bool = Query(False, description="restrict to the signed-in user's followed sources/tags"),
|
||||
request: Request = None,
|
||||
) -> FeedResponse:
|
||||
# Edge-cacheable ONLY when the response depends purely on the URL: not the
|
||||
# following feed (reads the session's follows) and not personal filters
|
||||
# (prefs/dismissals are per-reader). The shareable cases — the default
|
||||
# home feed, topic/flavor/tag/source browse — are identical for everyone,
|
||||
# so the edge can serve one copy to all. Everything else stays private.
|
||||
shareable = not following and not prefs and not exclude.strip()
|
||||
response.headers["Cache-Control"] = _EDGE_FEED if shareable else _PRIVATE
|
||||
if topic and topic.lower() not in TOPICS:
|
||||
raise HTTPException(400, f"unknown topic: {topic}")
|
||||
if flavor and flavor.lower() not in FLAVORS:
|
||||
|
||||
@@ -87,3 +87,23 @@ def test_families_endpoint(client):
|
||||
names = [f["name"] for f in fams]
|
||||
assert "Discovery & Wonder" in names
|
||||
assert all("tags" in f and isinstance(f["tags"], list) for f in fams)
|
||||
|
||||
|
||||
def test_global_endpoints_are_edge_cacheable(client):
|
||||
# The startup endpoints are identical for every visitor → publicly cacheable
|
||||
# so "Gathering the good news…" resolves from the edge, not the origin.
|
||||
for path in ("/api/moods", "/api/categories", "/api/lanes", "/api/families"):
|
||||
cc = client.get(path).headers.get("cache-control", "")
|
||||
assert "public" in cc and "s-maxage" in cc, f"{path}: {cc!r}"
|
||||
|
||||
|
||||
def test_feed_cache_boundary(client):
|
||||
# Shareable (URL-determined) feeds are public; personalized ones are private.
|
||||
public_cc = client.get("/api/feed").headers.get("cache-control", "")
|
||||
assert "public" in public_cc and "s-maxage" in public_cc
|
||||
# topic/tag browse is still shareable (same for everyone)
|
||||
assert "public" in client.get("/api/feed", params={"topic": "science"}).headers.get("cache-control", "")
|
||||
# personal filters + the following feed must never be shared across users
|
||||
assert client.get("/api/feed", params={"following": "true"}).headers.get("cache-control") == "private, no-store"
|
||||
assert client.get("/api/feed", params={"prefs": json.dumps({"mute_topics": ["science"]})}).headers.get("cache-control") == "private, no-store"
|
||||
assert client.get("/api/feed", params={"exclude": "1,2"}).headers.get("cache-control") == "private, no-store"
|
||||
|
||||
Reference in New Issue
Block a user