All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Browsers were caching the old index.html which still pointed at the pre-Wave-2 bundle hash. Hashed assets under /assets/ stay cacheable for a year (immutable), but index.html (and any SPA fallback) must revalidate every request so a fresh deploy is visible immediately.
204 lines
8.1 KiB
Python
204 lines
8.1 KiB
Python
"""FastAPI application — wires routers + middleware + lifespan.
|
|
|
|
Two deployment shapes:
|
|
|
|
- **Dev / tests**: `FRONTEND_DIST` unset. API routers mount at root
|
|
(e.g. `/networth`, `/scenarios`). The Vite dev server (port 5173)
|
|
hits `/api/*` and proxies to the backend stripping the prefix.
|
|
|
|
- **Prod**: `FRONTEND_DIST=/path/to/frontend/dist` set. API routers
|
|
mount under `/api/*`; the SPA static bundle mounts at `/` with
|
|
HTML fallback so React Router can own the URL.
|
|
|
|
Operational endpoints (`/healthz`, `/metrics`, `/recompute`) stay at
|
|
root in both shapes.
|
|
|
|
Auth: write/compute paths take Bearer auth via the `require_bearer`
|
|
dependency when `API_BEARER_TOKEN` is set. Read paths skip auth so the
|
|
local frontend can hit them without juggling tokens — production
|
|
deploys lock those down via Authentik-fronted ingress.
|
|
|
|
CORS: enabled for the frontend dev server. Comma-separated origins
|
|
in `FRONTEND_ORIGINS` (defaults to a typical Vite localhost).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import Depends, FastAPI, status
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from prometheus_fastapi_instrumentator import Instrumentator
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
from starlette.types import Scope
|
|
|
|
from fire_planner.api.auth import require_bearer
|
|
from fire_planner.api.cashflow import router as cashflow_router
|
|
from fire_planner.api.goals import router as goals_router
|
|
from fire_planner.api.income_streams import router as income_streams_router
|
|
from fire_planner.api.life_events import router as life_events_router
|
|
from fire_planner.api.networth import router as networth_router
|
|
from fire_planner.api.progress import router as progress_router
|
|
from fire_planner.api.scenarios import router as scenarios_router
|
|
from fire_planner.api.simulate import router as simulate_router
|
|
from fire_planner.api.spending import router as spending_router
|
|
from fire_planner.api.spending_profile import router as spending_profile_router
|
|
from fire_planner.api.year_stats import router as year_stats_router
|
|
from fire_planner.db import create_engine_from_env, make_session_factory
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def _frontend_origins() -> list[str]:
|
|
raw = os.environ.get(
|
|
"FRONTEND_ORIGINS",
|
|
"http://localhost:5173,http://localhost:4173,http://127.0.0.1:5173",
|
|
)
|
|
return [s.strip() for s in raw.split(",") if s.strip()]
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
app.state.queue = queue
|
|
if os.environ.get("DB_CONNECTION_STRING"):
|
|
engine = create_engine_from_env()
|
|
app.state.engine = engine
|
|
app.state.session_factory = make_session_factory(engine)
|
|
else:
|
|
# Tests inject these via dependency_overrides; nothing to wire.
|
|
log.warning("DB_CONNECTION_STRING unset; skipping engine init")
|
|
|
|
worker = asyncio.create_task(_drain_queue(app))
|
|
app.state._worker = worker
|
|
try:
|
|
yield
|
|
finally:
|
|
worker.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await worker
|
|
eng = getattr(app.state, "engine", None)
|
|
if eng is not None:
|
|
await eng.dispose()
|
|
|
|
|
|
async def _drain_queue(app: FastAPI) -> None:
|
|
"""Background task draining the recompute queue. Each item kicks
|
|
a full Cartesian recompute. Errors logged, don't crash."""
|
|
queue: asyncio.Queue[dict[str, Any]] = app.state.queue
|
|
while True:
|
|
item = await queue.get()
|
|
try:
|
|
from fire_planner.__main__ import _recompute_all
|
|
await _recompute_all(
|
|
n_paths=int(item.get("n_paths", 10_000)),
|
|
horizon=int(item.get("horizon", 60)),
|
|
spending=float(item.get("spending", 100_000.0)),
|
|
nw_seed=float(item.get("nw_seed", 1_000_000.0)),
|
|
savings=float(item.get("savings", 0.0)),
|
|
floor=(float(item["floor"]) if item.get("floor") is not None else None),
|
|
returns_csv=item.get("returns_csv"),
|
|
seed=int(item.get("seed", 42)),
|
|
)
|
|
except Exception:
|
|
log.exception("recompute failed")
|
|
finally:
|
|
queue.task_done()
|
|
|
|
|
|
_FRONTEND_DIST = os.environ.get("FRONTEND_DIST")
|
|
_API_PREFIX = "/api" if _FRONTEND_DIST else ""
|
|
|
|
app = FastAPI(title="fire-planner", lifespan=lifespan)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=_frontend_origins(),
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
Instrumentator().instrument(app).expose(app, endpoint="/metrics")
|
|
|
|
app.include_router(networth_router, prefix=_API_PREFIX)
|
|
app.include_router(scenarios_router, prefix=_API_PREFIX)
|
|
app.include_router(life_events_router, prefix=_API_PREFIX)
|
|
app.include_router(goals_router, prefix=_API_PREFIX)
|
|
app.include_router(income_streams_router, prefix=_API_PREFIX)
|
|
app.include_router(year_stats_router, prefix=_API_PREFIX)
|
|
app.include_router(progress_router, prefix=_API_PREFIX)
|
|
app.include_router(cashflow_router, prefix=_API_PREFIX)
|
|
app.include_router(spending_profile_router, prefix=_API_PREFIX)
|
|
app.include_router(simulate_router, prefix=_API_PREFIX)
|
|
app.include_router(spending_router, prefix=_API_PREFIX)
|
|
|
|
|
|
@app.post(
|
|
"/recompute",
|
|
status_code=status.HTTP_202_ACCEPTED,
|
|
dependencies=[Depends(require_bearer)],
|
|
)
|
|
async def recompute(payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
"""Queue a full Cartesian recompute (async, persisted). Returns 202."""
|
|
queue: asyncio.Queue[dict[str, Any]] = app.state.queue
|
|
await queue.put(payload or {})
|
|
return {"status": "accepted", "depth": queue.qsize()}
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz() -> dict[str, Any]:
|
|
queue = getattr(app.state, "queue", None)
|
|
depth = queue.qsize() if queue is not None else 0
|
|
return {"status": "ok", "queue_depth": depth}
|
|
|
|
|
|
class _SPAStaticFiles(StaticFiles):
|
|
"""StaticFiles with a SPA fallback: any 404 returns index.html so
|
|
React Router can own client-side routing for paths like /scenarios,
|
|
/what-if, /scenarios/123, etc.
|
|
|
|
`StaticFiles(html=True)` only serves index.html for *directories*,
|
|
not arbitrary not-found paths — that's not enough for a SPA.
|
|
|
|
Cache policy: Vite hashes assets (e.g. ``index-XjyVM1-C.js``) so they
|
|
are immutable and can be cached forever. ``index.html`` references
|
|
those by hash — if the browser caches a stale ``index.html`` it'll
|
|
keep loading the OLD bundle. Force ``index.html`` (and any non-hashed
|
|
response, including SPA fallbacks) to revalidate on every request.
|
|
"""
|
|
|
|
async def get_response(self, path: str, scope: Scope): # type: ignore[no-untyped-def]
|
|
try:
|
|
response = await super().get_response(path, scope)
|
|
except StarletteHTTPException as exc:
|
|
if exc.status_code == 404:
|
|
index = Path(self.directory) / "index.html" # type: ignore[arg-type]
|
|
if index.is_file():
|
|
return FileResponse(
|
|
index,
|
|
headers={"Cache-Control": "no-cache, must-revalidate"},
|
|
)
|
|
raise
|
|
# Hashed Vite assets live under /assets/ and contain an 8-char
|
|
# hash — safe to cache aggressively. index.html and anything
|
|
# else (logos, favicons) revalidate.
|
|
if path.startswith("assets/") and "-" in path:
|
|
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
else:
|
|
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
|
return response
|
|
|
|
|
|
# Mount the SPA last so it catches any path the API didn't claim.
|
|
if _FRONTEND_DIST and Path(_FRONTEND_DIST).is_dir():
|
|
app.mount("/", _SPAStaticFiles(directory=_FRONTEND_DIST, html=True), name="spa")
|
|
log.info("Mounted SPA from %s; API at %s/*", _FRONTEND_DIST, _API_PREFIX)
|
|
elif _FRONTEND_DIST:
|
|
log.warning("FRONTEND_DIST=%s does not exist; SPA not mounted", _FRONTEND_DIST)
|