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:
jay
2026-06-12 04:34:11 -04:00
parent 8435041b14
commit a34a47fe22
2 changed files with 46 additions and 4 deletions
+26 -4
View File
@@ -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:
+20
View File
@@ -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"