public overview: background refresh (inline recompute took 40 s); HTML no-cache, hashed assets immutable
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 changed files +42 −9
modified
services/api/src/findata_api/main.py
+13 −0
@@ -52,6 +52,12 @@ class Observability(BaseHTTPMiddleware): | ||
| 52 | 52 | M.REQUESTS.labels(request.method, _route_label(route), str(response.status_code)).inc() |
| 53 | 53 | M.LATENCY.labels(_route_label(route)).observe(dt) |
| 54 | 54 | response.headers["X-Request-ID"] = rid |
| 55 | + if not route.startswith("/api/") and route != "/metrics": | |
| 56 | + # static console: hashed assets are immutable, HTML must be revalidated so deployments show up at once | |
| 57 | + if route.startswith("/_next/static/"): | |
| 58 | + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" | |
| 59 | + else: | |
| 60 | + response.headers["Cache-Control"] = "no-cache" | |
| 55 | 61 | response.headers["X-Content-Type-Options"] = "nosniff" |
| 56 | 62 | response.headers["X-Frame-Options"] = "DENY" |
| 57 | 63 | response.headers["Referrer-Policy"] = "no-referrer" |
@@ -149,6 +155,13 @@ def create_app() -> FastAPI: | ||
| 149 | 155 | body, ctype = M.render() |
| 150 | 156 | return Response(body, media_type=ctype) |
| 151 | 157 | |
| 158 | + @app.on_event("startup") | |
| 159 | + def _warm() -> None: | |
| 160 | + from findata_api.health import start_refresher as _h | |
| 161 | + from findata_api.routers.public import start_refresher as _p | |
| 162 | + _h() | |
| 163 | + _p() | |
| 164 | + | |
| 152 | 165 | web = s.web_dist or Path(s.root) / "apps" / "web" / "out" |
| 153 | 166 | if web.exists(): |
| 154 | 167 | app.mount("/", StaticFiles(directory=str(web), html=True), name="web") |
modified
services/api/src/findata_api/routers/public.py
+29 −9
@@ -66,14 +66,34 @@ def _compute() -> dict[str, Any]: | ||
| 66 | 66 | } |
| 67 | 67 | |
| 68 | 68 | |
| 69 | +_started = False | |
| 70 | + | |
| 71 | + | |
| 72 | +def _refresh_loop(period_s: float) -> None: | |
| 73 | + while True: | |
| 74 | + try: | |
| 75 | + v = _compute() | |
| 76 | + _cache.update(at=time.monotonic(), value=v) | |
| 77 | + except Exception as e: | |
| 78 | + _cache.setdefault("error", str(e)[:200]) | |
| 79 | + time.sleep(period_s) | |
| 80 | + | |
| 81 | + | |
| 82 | +def start_refresher(period_s: float = 300.0) -> None: | |
| 83 | + global _started | |
| 84 | + if _started: | |
| 85 | + return | |
| 86 | + _started = True | |
| 87 | + threading.Thread(target=_refresh_loop, args=(period_s,), name="public-overview-refresher", daemon=True).start() | |
| 88 | + | |
| 89 | + | |
| 69 | 90 | @router.get("/overview") |
| 70 | 91 | def overview(): |
| 71 | − now = time.monotonic() | |
| 72 | − if _cache["value"] is not None and now - _cache["at"] < 120: | |
| 73 | − return _cache["value"] | |
| 74 | − with _lock: | |
| 75 | − if _cache["value"] is not None and time.monotonic() - _cache["at"] < 120: | |
| 76 | − return _cache["value"] | |
| 77 | − v = _compute() | |
| 78 | − _cache.update(at=time.monotonic(), value=v) | |
| 79 | − return v | |
| 92 | + """Served from a cache refreshed in the background every 5 minutes; the request path never runs the queries.""" | |
| 93 | + start_refresher() | |
| 94 | + v = _cache["value"] | |
| 95 | + if v is None: | |
| 96 | + return {"generated_at": datetime.now(UTC).isoformat(), "status": "starting", "totals": None, "families": [], "datasets": [], | |
| 97 | + "coverage": [], "options": {}, "fundamentals": {}, "instruments": {}, "freshness": {"jobs_24h": {}}, "storage": {}, | |
| 98 | + "backup": {}, "license": "FirstRate Data — private research use; raw bars and tables are not redistributed. Sign in for data access."} | |
| 99 | + return v | |
| 80 | 100 | |