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 .paywall import is_paywalled
|
||||||
from .taxonomy import FAMILIES, FLAVORS, TOPICS
|
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]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
|
DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
|
||||||
# Prefer the built SvelteKit site; fall back to the legacy single-page harness.
|
# Prefer the built SvelteKit site; fall back to the legacy single-page harness.
|
||||||
@@ -1410,23 +1420,26 @@ def create_app() -> FastAPI:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@app.get("/api/categories", response_model=CategoriesResponse)
|
@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(
|
return CategoriesResponse(
|
||||||
topics=[Category(key=k, description=v) for k, v in TOPICS.items()],
|
topics=[Category(key=k, description=v) for k, v in TOPICS.items()],
|
||||||
flavors=[Category(key=k, description=v) for k, v in FLAVORS.items()],
|
flavors=[Category(key=k, description=v) for k, v in FLAVORS.items()],
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/moods")
|
@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
|
# The humane front door: each mood resolves to a filter preset the
|
||||||
# client merges with the user's own Calm Filters.
|
# client merges with the user's own Calm Filters.
|
||||||
|
response.headers["Cache-Control"] = _EDGE_CONFIG # static presets, identical for everyone
|
||||||
return MOODS
|
return MOODS
|
||||||
|
|
||||||
@app.get("/api/lanes")
|
@app.get("/api/lanes")
|
||||||
def lanes() -> dict:
|
def lanes(response: Response) -> dict:
|
||||||
# The customizable quick-access rail: 'today' is always pinned, and the
|
# The customizable quick-access rail: 'today' is always pinned, and the
|
||||||
# reader pins any subset of these moods / topics / Discovery tags. Live
|
# reader pins any subset of these moods / topics / Discovery tags. Live
|
||||||
# counts let the client gate empty lanes and show volume.
|
# 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:
|
with get_conn() as conn:
|
||||||
tagc = queries.tag_counts(conn)
|
tagc = queries.tag_counts(conn)
|
||||||
topicc: dict[str, int] = {}
|
topicc: dict[str, int] = {}
|
||||||
@@ -1435,8 +1448,9 @@ def create_app() -> FastAPI:
|
|||||||
return build_lane_pool(topicc, tagc)
|
return build_lane_pool(topicc, tagc)
|
||||||
|
|
||||||
@app.get("/api/families")
|
@app.get("/api/families")
|
||||||
def families() -> list[dict]:
|
def families(response: Response) -> list[dict]:
|
||||||
# Grouping vocabulary organised into calm families for the Explore UI.
|
# 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:
|
with get_conn() as conn:
|
||||||
counts = queries.tag_counts(conn)
|
counts = queries.tag_counts(conn)
|
||||||
return [
|
return [
|
||||||
@@ -1468,6 +1482,7 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/api/feed", response_model=FeedResponse)
|
@app.get("/api/feed", response_model=FeedResponse)
|
||||||
def feed(
|
def feed(
|
||||||
|
response: Response,
|
||||||
topic: str | None = Query(None),
|
topic: str | None = Query(None),
|
||||||
flavor: str | None = Query(None),
|
flavor: str | None = Query(None),
|
||||||
accepted_only: bool = True,
|
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"),
|
following: bool = Query(False, description="restrict to the signed-in user's followed sources/tags"),
|
||||||
request: Request = None,
|
request: Request = None,
|
||||||
) -> FeedResponse:
|
) -> 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:
|
if topic and topic.lower() not in TOPICS:
|
||||||
raise HTTPException(400, f"unknown topic: {topic}")
|
raise HTTPException(400, f"unknown topic: {topic}")
|
||||||
if flavor and flavor.lower() not in FLAVORS:
|
if flavor and flavor.lower() not in FLAVORS:
|
||||||
|
|||||||
@@ -87,3 +87,23 @@ def test_families_endpoint(client):
|
|||||||
names = [f["name"] for f in fams]
|
names = [f["name"] for f in fams]
|
||||||
assert "Discovery & Wonder" in names
|
assert "Discovery & Wonder" in names
|
||||||
assert all("tags" in f and isinstance(f["tags"], list) for f in fams)
|
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