diff --git a/docs/CLOUDFLARE_FREE_CACHE_ZH.md b/docs/CLOUDFLARE_FREE_CACHE_ZH.md new file mode 100644 index 00000000..f733865c --- /dev/null +++ b/docs/CLOUDFLARE_FREE_CACHE_ZH.md @@ -0,0 +1,124 @@ +# Cloudflare 免费版缓存配置 + +目标:让 Cloudflare 承担公开页面、公开 API 和静态资源的重复读取,降低 VPS CPU、Next.js worker 和后端 detail-batch 压力,同时避免缓存账户、支付、反馈和实时连接。 + +代码已通过 `Cache-Control` 与 `Cloudflare-CDN-Cache-Control` 声明 TTL。Cloudflare Dashboard 应设置为尊重源站缓存头,不要给所有 `/api/*` 统一启用 Cache Everything。 + +缓存规则只允许作用于前端域名 `polyweather.top`。`api.polyweather.top` 是带服务令牌和会员校验的后端源站,必须绕过 Cloudflare 缓存,避免缓存命中绕过后端权限检查。 + +## 基础设置 + +- `Caching > Configuration > Browser Cache TTL`:Respect Existing Headers +- `Caching > Configuration > Development Mode`:Off +- `Caching > Configuration > Always Online`:On +- `Caching > Tiered Cache`:Smart Tiered Cache,若当前免费套餐控制台提供则开启 +- `Speed > Optimization > Content Optimization > Brotli`:On +- `Speed > Optimization > Content Optimization > Early Hints`:On +- `Network > HTTP/3 (with QUIC)`:On +- `Network > WebSockets`:On +- 确保 `api.polyweather.top` 与 `polyweather.top` 代理状态为 Proxied + +## Cache Rules + +按以下顺序创建,绕过规则必须放在公开缓存规则之前。免费版规则数量有限,因此使用路径集合合并表达式。 + +### 1. 绕过后端域名、动态和敏感请求 + +动作:Bypass cache + +表达式: + +```text +http.host eq "api.polyweather.top" +or (http.request.method ne "GET" and http.request.method ne "HEAD") +or starts_with(http.request.uri.path, "/api/auth/") +or starts_with(http.request.uri.path, "/api/feedback") +or starts_with(http.request.uri.path, "/api/events") +or starts_with(http.request.uri.path, "/api/internal/") +or starts_with(http.request.uri.path, "/api/ops/") +or starts_with(http.request.uri.path, "/api/payments/") +or starts_with(http.request.uri.path, "/account") +or starts_with(http.request.uri.path, "/auth") +or starts_with(http.request.uri.path, "/ops") +or starts_with(http.request.uri.path, "/terminal") +or http.request.uri.query contains "force_refresh=true" +``` + +若 Dashboard 支持请求头或 Cookie 条件,再加入: + +```text +or any(http.request.headers["authorization"][*] ne "") +or http.cookie contains "sb-" +``` + +### 2. 缓存静态资源 + +动作:Eligible for cache;Edge TTL 使用源站 Cache-Control。 + +表达式: + +```text +http.host eq "polyweather.top" +and ( + starts_with(http.request.uri.path, "/_next/static/") + or lower(http.request.uri.path.extension) in { + "js" "css" "woff" "woff2" "png" "jpg" "jpeg" "webp" "avif" "svg" "ico" + } +) +``` + +源站 TTL:一年 immutable。 + +### 3. 缓存公开页面 + +动作:Eligible for cache;Edge TTL 使用源站 Cache-Control。 + +表达式: + +```text +http.host eq "polyweather.top" +and ( + http.request.uri.path eq "/" + or starts_with(http.request.uri.path, "/docs/") + or starts_with(http.request.uri.path, "/modern/") + or starts_with(http.request.uri.path, "/probabilities/") + or starts_with(http.request.uri.path, "/subscription-help/") +) +``` + +源站 TTL:10 分钟,过期后允许后台刷新 1 小时。 + +### 4. 缓存公开数据接口 + +动作:Eligible for cache;Edge TTL 使用源站 Cache-Control。 + +表达式: + +```text +http.host eq "polyweather.top" +and ( + http.request.uri.path eq "/api/cities" + or http.request.uri.path eq "/api/cities/detail-batch" + or starts_with(http.request.uri.path, "/api/city/") + or http.request.uri.path eq "/api/scan/terminal" + or http.request.uri.path eq "/api/system/status" +) +``` + +接口 TTL: + +- `/api/cities`:5 分钟,过期后可复用 1 小时。 +- 城市详情与 detail-batch:60 秒,过期后可复用 5 分钟。 +- `/api/scan/terminal`:5 分钟,过期后可复用 15 分钟。 +- partial、busy、timeout、stale、force refresh 和错误响应:`no-store`,不得缓存。 + +## 验证 + +每次规则变更后连续请求同一 URL,检查响应头: + +```powershell +curl.exe -sS -D - -o NUL "https://polyweather.top/api/cities" +curl.exe -sS -D - -o NUL "https://polyweather.top/api/scan/terminal?limit=1" +``` + +正常命中应看到 `CF-Cache-Status: HIT` 和递增的 `Age`。首次请求通常是 `MISS`;动态或明确 `no-store` 的请求应保持 `DYNAMIC` 或 `BYPASS`。 diff --git a/frontend/app/api/cities/detail-batch/route.ts b/frontend/app/api/cities/detail-batch/route.ts index d15f816a..e697aebc 100644 --- a/frontend/app/api/cities/detail-batch/route.ts +++ b/frontend/app/api/cities/detail-batch/route.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { proxyBackendJsonGet } from "@/lib/api-proxy"; -import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy"; +import { + buildCityDetailProxyCachePolicy, + NO_STORE_CACHE_CONTROL, +} from "@/lib/proxy-cache-policy"; import { createProxyTimer, finishProxyTimedResponse, @@ -79,7 +82,7 @@ export async function GET(req: NextRequest) { const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; const requestedCities = parseRequestedCities(req); - const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15); + const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh); const searchParams = new URLSearchParams({ cities: req.nextUrl.searchParams.get("cities") || "", force_refresh: forceRefresh, @@ -100,7 +103,7 @@ export async function GET(req: NextRequest) { data && typeof data === "object" && (data as { partial?: unknown }).partial === true - ? "no-store, max-age=0" + ? NO_STORE_CACHE_CONTROL : cachePolicy.responseCacheControl, fetchCache: "no-store", publicMessage: "Failed to fetch city detail batch", @@ -108,7 +111,10 @@ export async function GET(req: NextRequest) { signal: controller.signal, timeoutResponse: () => NextResponse.json(buildCityDetailBatchTimeoutPayload(requestedCities), { - headers: { "Cache-Control": "no-store, max-age=0" }, + headers: { + "Cache-Control": NO_STORE_CACHE_CONTROL, + "Cloudflare-CDN-Cache-Control": NO_STORE_CACHE_CONTROL, + }, status: 200, }), timeoutPublicMessage: "City detail batch request timed out", diff --git a/frontend/app/api/cities/route.ts b/frontend/app/api/cities/route.ts index f6e709d3..28a7bfe6 100644 --- a/frontend/app/api/cities/route.ts +++ b/frontend/app/api/cities/route.ts @@ -1,12 +1,15 @@ import { NextRequest } from "next/server"; import { proxyBackendJsonGet } from "@/lib/api-proxy"; import { buildCachedJsonResponse } from "@/lib/http-cache"; +import { + buildCityListCacheControl, + buildStaticCityListFallbackCacheControl, +} from "@/lib/proxy-cache-policy"; import { STATIC_CITY_LIST } from "@/lib/static-cities"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; -const CITIES_CACHE_CONTROL = "public, max-age=0, s-maxage=60, stale-while-revalidate=300"; -const STATIC_CITIES_CACHE_CONTROL = - "public, max-age=0, s-maxage=300, stale-while-revalidate=3600"; +const CITIES_CACHE_CONTROL = buildCityListCacheControl(); +const STATIC_CITIES_CACHE_CONTROL = buildStaticCityListFallbackCacheControl(); const CITIES_BACKEND_TIMEOUT_MS = Number( process.env.POLYWEATHER_CITIES_BACKEND_TIMEOUT_MS || 1000, ); diff --git a/frontend/app/api/city/[name]/detail/route.ts b/frontend/app/api/city/[name]/detail/route.ts index b4c9e069..ecdb9204 100644 --- a/frontend/app/api/city/[name]/detail/route.ts +++ b/frontend/app/api/city/[name]/detail/route.ts @@ -49,7 +49,7 @@ export async function GET( const { name } = await timer.measure("route_params", () => context.params); const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; - const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15); + const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh); const depth = req.nextUrl.searchParams.get("depth"); const marketSlug = req.nextUrl.searchParams.get("market_slug"); const targetDate = req.nextUrl.searchParams.get("target_date"); @@ -82,7 +82,7 @@ export async function GET( headers: auth.headers, ...(cachePolicy.fetchMode === "no-store" ? { cache: "no-store" as const } - : { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }), + : { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }), }), ); const backendServerTiming = res.headers.get("server-timing") || ""; diff --git a/frontend/app/api/city/[name]/route.ts b/frontend/app/api/city/[name]/route.ts index 0117a74b..ce0b9022 100644 --- a/frontend/app/api/city/[name]/route.ts +++ b/frontend/app/api/city/[name]/route.ts @@ -139,7 +139,7 @@ export async function GET( const { name } = await context.params; const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; const depth = req.nextUrl.searchParams.get("depth") ?? "panel"; - const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15); + const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh); const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}&depth=${encodeURIComponent(depth)}`; try { @@ -150,7 +150,7 @@ export async function GET( headers: auth.headers, ...(cachePolicy.fetchMode === "no-store" ? { cache: "no-store" as const } - : { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }), + : { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }), }); if (!res.ok) { const raw = await res.text(); @@ -159,7 +159,7 @@ export async function GET( headers: auth.headers, ...(cachePolicy.fetchMode === "no-store" ? { cache: "no-store" as const } - : { next: { revalidate: 10 } }), + : { next: { revalidate: cachePolicy.revalidateSeconds ?? 60 } }), }); if (summaryRes.ok) { const summaryData = await summaryRes.json(); @@ -168,7 +168,7 @@ export async function GET( buildFallbackCityDetail(name, depth, summaryData), cachePolicy.fetchMode === "no-store" ? cachePolicy.responseCacheControl - : "public, max-age=0, s-maxage=10, stale-while-revalidate=30", + : cachePolicy.responseCacheControl, ); response.headers.set("X-PolyWeather-Fallback", "summary"); return applyAuthResponseCookies(response, auth.response); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts index 79db674a..3f888e90 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts @@ -2,10 +2,13 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import { + buildCityListCacheControl, buildScanTerminalResponseCacheControl, buildCityDetailProxyCachePolicy, buildForceRefreshProxyCachePolicy, + buildPublicEdgeCacheControl, isForceRefreshValue, + NO_STORE_CACHE_CONTROL, } from "@/lib/proxy-cache-policy"; export function runTests() { @@ -18,11 +21,21 @@ export function runTests() { assert.match(forced.responseCacheControl, /no-store/); assert.equal(forced.revalidateSeconds, undefined); - const cached = buildCityDetailProxyCachePolicy("false", 15); + const cached = buildCityDetailProxyCachePolicy("false"); assert.equal(cached.fetchMode, "revalidate"); - assert.equal(cached.revalidateSeconds, 15); - assert.match(cached.responseCacheControl, /max-age=15/); - assert.match(cached.responseCacheControl, /s-maxage=15/); + assert.equal(cached.revalidateSeconds, 60); + assert.match(cached.responseCacheControl, /max-age=30/); + assert.match(cached.responseCacheControl, /s-maxage=60/); + assert.match(cached.responseCacheControl, /stale-while-revalidate=300/); + + assert.equal( + buildPublicEdgeCacheControl(60, 300), + "public, max-age=0, s-maxage=60, stale-while-revalidate=300", + ); + assert.equal( + buildCityListCacheControl(), + "public, max-age=0, s-maxage=300, stale-while-revalidate=3600", + ); const scanForced = buildForceRefreshProxyCachePolicy("true", 10); assert.equal(scanForced.fetchMode, "no-store"); @@ -34,15 +47,15 @@ export function runTests() { ); assert.equal( buildScanTerminalResponseCacheControl({ status: "failed", stale: false }, normalScanCache), - "no-store, max-age=0", + NO_STORE_CACHE_CONTROL, ); assert.equal( buildScanTerminalResponseCacheControl({ status: "partial", stale: false }, normalScanCache), - "no-store, max-age=0", + NO_STORE_CACHE_CONTROL, ); assert.equal( buildScanTerminalResponseCacheControl({ status: "ready", stale: true }, normalScanCache), - "no-store, max-age=0", + NO_STORE_CACHE_CONTROL, ); const scanTerminalProxySource = fs.readFileSync( diff --git a/frontend/components/ops/__tests__/apiPerformanceTiming.test.ts b/frontend/components/ops/__tests__/apiPerformanceTiming.test.ts index 245f7664..1b60fb8d 100644 --- a/frontend/components/ops/__tests__/apiPerformanceTiming.test.ts +++ b/frontend/components/ops/__tests__/apiPerformanceTiming.test.ts @@ -101,6 +101,16 @@ export function runTests() { /cacheControlForData\?:/, "generic backend JSON proxy should allow response cache policy to depend on parsed data", ); + assert.match( + apiProxySource, + /Cloudflare-CDN-Cache-Control/, + "generic backend JSON proxy should expose Cloudflare-specific cache directives", + ); + assert.match( + apiProxySource, + /NO_STORE_CACHE_CONTROL/, + "generic backend JSON proxy errors must remain non-cacheable when Cache Everything is enabled", + ); const scanTerminalProxy = readFrontend("app", "api", "scan", "terminal", "route.ts"); assert.match(scanTerminalProxy, /createProxyTimer\(req,\s*"scan_terminal"\)/); diff --git a/frontend/lib/api-proxy.ts b/frontend/lib/api-proxy.ts index 2f7c4598..4f4f4902 100644 --- a/frontend/lib/api-proxy.ts +++ b/frontend/lib/api-proxy.ts @@ -9,6 +9,7 @@ import { finishProxyTimedResponse, type ProxyTimer, } from "@/lib/proxy-timing"; +import { NO_STORE_CACHE_CONTROL } from "@/lib/proxy-cache-policy"; const PASSTHROUGH_UPSTREAM_STATUSES = new Set([ 400, @@ -85,6 +86,10 @@ export function buildUpstreamErrorResponse( } return NextResponse.json(body, { + headers: { + "Cache-Control": NO_STORE_CACHE_CONTROL, + "Cloudflare-CDN-Cache-Control": NO_STORE_CACHE_CONTROL, + }, status: clientStatusFromUpstream(upstreamStatus), }); } @@ -106,7 +111,13 @@ export function buildProxyExceptionResponse( body.detail = String(error); } - return NextResponse.json(body, { status: options.status ?? 500 }); + return NextResponse.json(body, { + headers: { + "Cache-Control": NO_STORE_CACHE_CONTROL, + "Cloudflare-CDN-Cache-Control": NO_STORE_CACHE_CONTROL, + }, + status: options.status ?? 500, + }); } export async function proxyBackendJsonGet( @@ -185,7 +196,10 @@ export async function proxyBackendJsonGet( ? buildCachedJsonResponse(req, data, responseCacheControl) : NextResponse.json(data, { headers: responseCacheControl - ? { "Cache-Control": responseCacheControl } + ? { + "Cache-Control": responseCacheControl, + "Cloudflare-CDN-Cache-Control": responseCacheControl, + } : undefined, }); const withCookies = applyAuthResponseCookies(response, auth.response); diff --git a/frontend/lib/http-cache.ts b/frontend/lib/http-cache.ts index a0fc368a..a13880d3 100644 --- a/frontend/lib/http-cache.ts +++ b/frontend/lib/http-cache.ts @@ -28,6 +28,7 @@ export function buildCachedJsonResponse( const etag = buildEtag(body); const headers = new Headers({ "Cache-Control": cacheControl, + "Cloudflare-CDN-Cache-Control": cacheControl, ETag: etag, "Content-Type": "application/json; charset=utf-8", }); diff --git a/frontend/lib/proxy-cache-policy.ts b/frontend/lib/proxy-cache-policy.ts index a01981ad..e46e0dba 100644 --- a/frontend/lib/proxy-cache-policy.ts +++ b/frontend/lib/proxy-cache-policy.ts @@ -4,7 +4,32 @@ export type ProxyCachePolicy = { revalidateSeconds?: number; }; -const NO_STORE_CACHE_CONTROL = "no-store, max-age=0"; +export const NO_STORE_CACHE_CONTROL = "no-store, max-age=0"; + +export const CLOUDFLARE_EDGE_TTL_SEC = { + cityDetail: 60, + cityDetailStale: 300, + cityList: 300, + cityListStale: 3600, + landingPage: 600, + scanTerminal: 300, + scanTerminalStale: 900, + staticAsset: 31536000, + systemStatus: 60, +} as const; + +export function buildPublicEdgeCacheControl( + sMaxageSeconds: number, + staleWhileRevalidateSeconds = Math.max(sMaxageSeconds * 3, 30), + browserMaxAgeSeconds = 0, +) { + return [ + "public", + `max-age=${Math.max(0, Math.floor(browserMaxAgeSeconds))}`, + `s-maxage=${Math.max(1, Math.floor(sMaxageSeconds))}`, + `stale-while-revalidate=${Math.max(0, Math.floor(staleWhileRevalidateSeconds))}`, + ].join(", "); +} export function isForceRefreshValue(value: string | null | undefined) { return String(value || "").trim().toLowerCase() === "true"; @@ -12,7 +37,7 @@ export function isForceRefreshValue(value: string | null | undefined) { export function buildForceRefreshProxyCachePolicy( forceRefresh: string | null | undefined, - revalidateSeconds = 15, + revalidateSeconds: number = CLOUDFLARE_EDGE_TTL_SEC.scanTerminal, ): ProxyCachePolicy { if (isForceRefreshValue(forceRefresh)) { return { @@ -22,17 +47,17 @@ export function buildForceRefreshProxyCachePolicy( } return { fetchMode: "revalidate", - responseCacheControl: `public, max-age=0, s-maxage=${revalidateSeconds}, stale-while-revalidate=${Math.max( - revalidateSeconds * 3, - 30, - )}`, + responseCacheControl: buildPublicEdgeCacheControl( + revalidateSeconds, + Math.max(revalidateSeconds * 3, CLOUDFLARE_EDGE_TTL_SEC.scanTerminalStale), + ), revalidateSeconds, }; } export function buildCityDetailProxyCachePolicy( forceRefresh: string | null | undefined, - revalidateSeconds = 15, + revalidateSeconds: number = CLOUDFLARE_EDGE_TTL_SEC.cityDetail, ): ProxyCachePolicy { if (isForceRefreshValue(forceRefresh)) { return { @@ -42,14 +67,29 @@ export function buildCityDetailProxyCachePolicy( } return { fetchMode: "revalidate", - responseCacheControl: `public, max-age=${revalidateSeconds}, s-maxage=${revalidateSeconds}, stale-while-revalidate=${Math.max( - revalidateSeconds * 3, - 30, - )}`, + responseCacheControl: buildPublicEdgeCacheControl( + revalidateSeconds, + Math.max(revalidateSeconds * 3, CLOUDFLARE_EDGE_TTL_SEC.cityDetailStale), + Math.min(revalidateSeconds, 30), + ), revalidateSeconds, }; } +export function buildCityListCacheControl() { + return buildPublicEdgeCacheControl( + CLOUDFLARE_EDGE_TTL_SEC.cityList, + CLOUDFLARE_EDGE_TTL_SEC.cityListStale, + ); +} + +export function buildStaticCityListFallbackCacheControl() { + return buildPublicEdgeCacheControl( + CLOUDFLARE_EDGE_TTL_SEC.cityList, + CLOUDFLARE_EDGE_TTL_SEC.cityListStale, + ); +} + export function buildScanTerminalResponseCacheControl( data: unknown, readyCacheControl: string, diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 6b7bc6ba..06f9385c 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -3,15 +3,40 @@ const nextConfig = { output: "standalone", reactStrictMode: true, async headers() { - const cacheHeader = { + const immutableCacheHeader = { key: "Cache-Control", value: "public, max-age=31536000, immutable", }; + const immutableCloudflareCacheHeader = { + key: "Cloudflare-CDN-Cache-Control", + value: "public, max-age=31536000, immutable", + }; + const publicPageHeaders = [ + { + key: "Cache-Control", + value: "public, max-age=0, s-maxage=600, stale-while-revalidate=3600", + }, + { + key: "Cloudflare-CDN-Cache-Control", + value: "public, max-age=600, stale-while-revalidate=3600", + }, + ]; const staticExts = ["jpg", "jpeg", "png", "gif", "ico", "svg", "webp", "avif", "woff2", "ttf", "eot", "css", "js"]; - return staticExts.map((ext) => ({ + const staticAssetRules = staticExts.map((ext) => ({ source: `/:path(.+\\.${ext})`, - headers: [cacheHeader], + headers: [immutableCacheHeader, immutableCloudflareCacheHeader], })); + const publicPageRules = [ + "/", + "/docs/:path*", + "/modern/:path*", + "/probabilities/:path*", + "/subscription-help/:path*", + ].map((source) => ({ + source, + headers: publicPageHeaders, + })); + return [...staticAssetRules, ...publicPageRules]; }, }; diff --git a/tests/test_api_performance_timing.py b/tests/test_api_performance_timing.py index 0589d19c..3b34cab3 100644 --- a/tests/test_api_performance_timing.py +++ b/tests/test_api_performance_timing.py @@ -84,6 +84,10 @@ def test_city_detail_batch_response_includes_backend_server_timing(monkeypatch): assert "city_detail_batch_full_data_paris" in server_timing assert "city_detail_batch_detail_payload_paris" in server_timing assert "city_detail_batch_total" in server_timing + assert response.headers["cache-control"] == ( + "public, max-age=30, s-maxage=60, stale-while-revalidate=300" + ) + assert response.headers["cloudflare-cdn-cache-control"] == response.headers["cache-control"] def test_city_detail_response_includes_backend_server_timing(monkeypatch): @@ -125,7 +129,12 @@ def test_scan_terminal_response_includes_backend_server_timing(monkeypatch): monkeypatch.setattr( scan_api.legacy_routes, "build_scan_terminal_payload", - lambda filters, force_refresh=False, timing_recorder=None: {"rows": [], "filters": filters}, + lambda filters, force_refresh=False, timing_recorder=None: { + "rows": [], + "filters": filters, + "status": "ready", + "stale": False, + }, ) response = client.get("/api/scan/terminal?limit=1") @@ -135,6 +144,30 @@ def test_scan_terminal_response_includes_backend_server_timing(monkeypatch): assert "scan_terminal_assert_entitlement" in server_timing assert "scan_terminal_build_payload" in server_timing assert "scan_terminal_total" in server_timing + assert response.headers["cache-control"] == ( + "public, max-age=0, s-maxage=300, stale-while-revalidate=900" + ) + assert response.headers["cloudflare-cdn-cache-control"] == response.headers["cache-control"] + + +def test_scan_terminal_stale_response_is_not_cached(monkeypatch): + monkeypatch.setattr(scan_api.legacy_routes, "_assert_entitlement", lambda request: None) + monkeypatch.setattr( + scan_api.legacy_routes, + "build_scan_terminal_payload", + lambda filters, force_refresh=False, timing_recorder=None: { + "rows": [], + "filters": filters, + "status": "ready", + "stale": True, + }, + ) + + response = client.get("/api/scan/terminal?limit=1") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store, max-age=0" + assert response.headers["cloudflare-cdn-cache-control"] == "no-store, max-age=0" def test_online_users_response_includes_backend_server_timing(): diff --git a/tests/test_cache_headers.py b/tests/test_cache_headers.py new file mode 100644 index 00000000..db828275 --- /dev/null +++ b/tests/test_cache_headers.py @@ -0,0 +1,31 @@ +from starlette.datastructures import MutableHeaders + +from web.services.cache_headers import ( + NO_STORE_CACHE_CONTROL, + apply_cache_control, + apply_no_store, + public_edge_cache_control, +) + + +def test_public_edge_cache_control_separates_browser_and_edge_ttl(): + assert public_edge_cache_control( + 60, + 300, + browser_max_age_seconds=30, + ) == "public, max-age=30, s-maxage=60, stale-while-revalidate=300" + + +def test_cache_helpers_set_cloudflare_specific_header(): + headers = MutableHeaders() + cache_control = public_edge_cache_control(300, 900) + + apply_cache_control(headers, cache_control) + + assert headers["cache-control"] == cache_control + assert headers["cloudflare-cdn-cache-control"] == cache_control + + apply_no_store(headers) + + assert headers["cache-control"] == NO_STORE_CACHE_CONTROL + assert headers["cloudflare-cdn-cache-control"] == NO_STORE_CACHE_CONTROL diff --git a/tests/test_sse_replay.py b/tests/test_sse_replay.py index 0e1ea500..c26f6b7b 100644 --- a/tests/test_sse_replay.py +++ b/tests/test_sse_replay.py @@ -83,6 +83,8 @@ def test_events_endpoint_replays_only_requested_cities(monkeypatch): ) assert response.status_code == 200 + assert response.headers["cache-control"] == "no-cache, no-transform" + assert response.headers["cloudflare-cdn-cache-control"] == "no-store" assert captured == { "cities": {"taipei", "hong kong"}, "since_revision": 42, diff --git a/web/routers/city.py b/web/routers/city.py index f5b8a9f7..fdd21860 100644 --- a/web/routers/city.py +++ b/web/routers/city.py @@ -4,6 +4,11 @@ from typing import Any, Dict, List, Optional from fastapi import APIRouter, BackgroundTasks, Query, Request, Response +from web.services.cache_headers import ( + apply_cache_control, + apply_no_store, + public_edge_cache_control, +) from web.services.city_api import ( get_city_detail_batch_payload, get_city_detail_aggregate_payload, @@ -16,6 +21,14 @@ from web.services.request_timing import attach_server_timing_header router = APIRouter(tags=["city"]) +CITY_LIST_CACHE_CONTROL = public_edge_cache_control(300, 3600) +CITY_DETAIL_CACHE_CONTROL = public_edge_cache_control( + 60, + 300, + browser_max_age_seconds=30, +) + + def _all_city_keys() -> List[str]: from src.data_collection.city_registry import CITY_REGISTRY @@ -36,8 +49,10 @@ _MODEL_RANGE_NAMES: Dict[str, str] = {c: _city_display_name(c) for c in _MODEL_R @router.get("/api/cities") -async def list_cities(request: Request): - return await list_cities_payload(request) +async def list_cities(request: Request, response: Response): + payload = await list_cities_payload(request) + apply_cache_control(response.headers, CITY_LIST_CACHE_CONTROL) + return payload def _extract_city_model_range(city: str, _force_refresh: bool) -> Optional[Dict[str, Any]]: @@ -128,6 +143,12 @@ async def city_detail_batch( scope=scope, limit=limit, ) + if force_refresh or bool(payload.get("partial")) or bool(payload.get("busy")) or bool( + payload.get("timeout") + ): + apply_no_store(response.headers) + else: + apply_cache_control(response.headers, CITY_DETAIL_CACHE_CONTROL) attach_server_timing_header(response, request, "city_detail_batch_server_timing") return payload @@ -135,31 +156,43 @@ async def city_detail_batch( @router.get("/api/city/{name}") async def city_detail( request: Request, + response: Response, background_tasks: BackgroundTasks, name: str, force_refresh: bool = False, depth: str = "panel", ): - return await get_city_detail_payload( + payload = await get_city_detail_payload( request, name, force_refresh=force_refresh, depth=depth, ) + if force_refresh: + apply_no_store(response.headers) + else: + apply_cache_control(response.headers, CITY_DETAIL_CACHE_CONTROL) + return payload @router.get("/api/city/{name}/summary") async def city_summary( request: Request, + response: Response, background_tasks: BackgroundTasks, name: str, force_refresh: bool = False, ): - return await get_city_summary_payload( + payload = await get_city_summary_payload( request, name, force_refresh=force_refresh, ) + if force_refresh: + apply_no_store(response.headers) + else: + apply_cache_control(response.headers, CITY_DETAIL_CACHE_CONTROL) + return payload @router.get("/api/city/{name}/detail") @@ -180,6 +213,10 @@ async def city_detail_aggregate( target_date=target_date, resolution=resolution, ) + if force_refresh: + apply_no_store(response.headers) + else: + apply_cache_control(response.headers, CITY_DETAIL_CACHE_CONTROL) attach_server_timing_header(response, request, "city_detail_server_timing") return payload diff --git a/web/routers/scan.py b/web/routers/scan.py index fce64368..78366c0f 100644 --- a/web/routers/scan.py +++ b/web/routers/scan.py @@ -5,6 +5,7 @@ from __future__ import annotations from fastapi import APIRouter, Request from fastapi.responses import JSONResponse +from web.services.cache_headers import NO_STORE_CACHE_CONTROL, public_edge_cache_control from web.services.scan_api import ( get_scan_terminal_overview_payload, get_scan_terminal_payload, @@ -13,6 +14,8 @@ from web.services.request_timing import attach_server_timing_header router = APIRouter(tags=["scan"]) +SCAN_TERMINAL_CACHE_CONTROL = public_edge_cache_control(300, 900) + @router.get("/api/scan/terminal") async def scan_terminal( @@ -46,10 +49,17 @@ async def scan_terminal( region=region or trading_region or None, timezone_offset_seconds=timezone_offset_seconds, ) + status = str(payload.get("status") or "").strip().lower() + cache_control = ( + SCAN_TERMINAL_CACHE_CONTROL + if not force_refresh and status == "ready" and payload.get("stale") is not True + else NO_STORE_CACHE_CONTROL + ) response = JSONResponse( content=payload, headers={ - "Cache-Control": "public, s-maxage=30, stale-while-revalidate=120", + "Cache-Control": cache_control, + "Cloudflare-CDN-Cache-Control": cache_control, }, ) attach_server_timing_header(response, request, "scan_terminal_server_timing") diff --git a/web/routers/sse_router.py b/web/routers/sse_router.py index ee100139..63144746 100644 --- a/web/routers/sse_router.py +++ b/web/routers/sse_router.py @@ -147,6 +147,7 @@ async def sse_events( media_type="text/event-stream", headers={ "Cache-Control": "no-cache, no-transform", + "Cloudflare-CDN-Cache-Control": "no-store", "Connection": "keep-alive", "X-Accel-Buffering": "no", "Access-Control-Allow-Origin": origin if allowed else "https://polyweather.top", diff --git a/web/services/cache_headers.py b/web/services/cache_headers.py new file mode 100644 index 00000000..3ade74d0 --- /dev/null +++ b/web/services/cache_headers.py @@ -0,0 +1,29 @@ +"""Shared public-cache headers for Cloudflare and other CDNs.""" + +from __future__ import annotations + +from typing import MutableMapping + +NO_STORE_CACHE_CONTROL = "no-store, max-age=0" + + +def public_edge_cache_control( + s_maxage_seconds: int, + stale_while_revalidate_seconds: int, + *, + browser_max_age_seconds: int = 0, +) -> str: + return ( + f"public, max-age={max(0, int(browser_max_age_seconds))}, " + f"s-maxage={max(1, int(s_maxage_seconds))}, " + f"stale-while-revalidate={max(0, int(stale_while_revalidate_seconds))}" + ) + + +def apply_cache_control(headers: MutableMapping[str, str], cache_control: str) -> None: + headers["Cache-Control"] = cache_control + headers["Cloudflare-CDN-Cache-Control"] = cache_control + + +def apply_no_store(headers: MutableMapping[str, str]) -> None: + apply_cache_control(headers, NO_STORE_CACHE_CONTROL)