perf: cache aggressively and trim client bundle
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 1m1s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m4s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s

Frontend
- Dynamic-import Chart.js components on detail/compare views so Chart.js
  no longer ships in initial JS.
- Drop force-dynamic on home, compare, rankings so internal data fetches
  reuse Next.js's per-call revalidate cache.
- Switch /school/[slug] to ISR with a 7-day revalidate window (school
  data updates annually).
- Preconnect to analytics + postcodes.io; remove redundant defer on the
  Umami Script tag (afterInteractive already covers it).
- Bump images.minimumCacheTTL to 1 year.
- Extract HowItWorks and Editorial sections as server components passed
  to HomeView via slot props so their JSX stays out of the client bundle.

Backend
- Add GZipMiddleware (min 512 bytes).
- Add CacheAndETagMiddleware: per-path Cache-Control with long s-maxage
  + stale-while-revalidate, ETag generation, and 304 on If-None-Match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tudor Sitaru
2026-06-02 13:46:45 +01:00
parent a7ab624a01
commit 62eeee5f7c
14 changed files with 349 additions and 207 deletions
+69 -1
View File
@@ -4,6 +4,7 @@ Serves primary and secondary school performance data for comparing schools.
Uses real data from UK Government Compare School Performance downloads.
"""
import hashlib
import re
from contextlib import asynccontextmanager
from typing import Optional
@@ -12,6 +13,7 @@ import numpy as np
import pandas as pd
from fastapi import FastAPI, HTTPException, Query, Request, Depends, Header
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from slowapi import Limiter, _rate_limit_exceeded_handler
@@ -165,6 +167,69 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
return response
# Per-path Cache-Control rules. Keys are matched as path prefixes (longest wins).
# Values: (max_age, s_maxage, stale_while_revalidate)
CACHE_RULES: list[tuple[str, tuple[int, int, int]]] = [
("/api/filters", (300, 86400, 604800)),
("/api/metrics", (300, 86400, 604800)),
("/api/national-averages", (300, 86400, 604800)),
("/api/la-averages", (300, 86400, 604800)),
("/api/data-info", (300, 86400, 604800)),
("/api/schools/", (300, 3600, 86400)), # /api/schools/{urn}
("/api/rankings", (60, 600, 3600)),
("/api/compare", (60, 600, 3600)),
("/api/schools", (30, 300, 1800)), # search list
]
def _cache_control_for_path(path: str) -> Optional[str]:
# Longest-prefix match
best: Optional[tuple[int, tuple[int, int, int]]] = None
for prefix, vals in CACHE_RULES:
if path.startswith(prefix) and (best is None or len(prefix) > best[0]):
best = (len(prefix), vals)
if best is None:
return None
max_age, s_maxage, swr = best[1]
return f"public, max-age={max_age}, s-maxage={s_maxage}, stale-while-revalidate={swr}"
class CacheAndETagMiddleware(BaseHTTPMiddleware):
"""Set Cache-Control on cacheable API responses and serve 304s via ETag."""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Only cache GETs that succeeded.
if request.method != "GET" or response.status_code != 200:
return response
cache_header = _cache_control_for_path(request.url.path)
if cache_header is None:
return response
# Drain body so we can hash it for ETag.
body_chunks = []
async for chunk in response.body_iterator:
body_chunks.append(chunk)
body = b"".join(body_chunks)
etag = '"' + hashlib.md5(body).hexdigest() + '"'
headers = dict(response.headers)
headers["Cache-Control"] = cache_header
headers["ETag"] = etag
headers["Vary"] = ", ".join(filter(None, [headers.get("Vary"), "Accept-Encoding"]))
inm = request.headers.get("if-none-match")
if inm and inm == etag:
# Strip content headers on 304.
for h in ("Content-Length", "content-length", "Content-Type", "content-type"):
headers.pop(h, None)
return Response(status_code=304, headers=headers)
return Response(content=body, status_code=200, headers=headers, media_type=response.media_type)
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
"""Limit request body size to prevent DoS attacks."""
@@ -253,9 +318,12 @@ app = FastAPI(
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Security middleware (order matters - these run in reverse order)
# Middleware (Starlette runs the last-added middleware first on the way out,
# so list outermost-last: GZip wraps everything and compresses the final body).
app.add_middleware(CacheAndETagMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(RequestSizeLimitMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=512)
# CORS middleware - restricted for production
app.add_middleware(