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
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:
+69
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user