From 00f96150c5f1d2b6d75da52c637dd348cbc5185e Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 3 Jun 2026 02:34:27 +0800 Subject: [PATCH] fix: prevent slow chart proxy 504s --- frontend/app/api/cities/detail-batch/route.ts | 38 +++++++++++++++++++ frontend/app/api/scan/terminal/route.ts | 2 +- .../__tests__/apiPerformanceTiming.test.ts | 30 +++++++++++++++ frontend/lib/api-proxy.ts | 30 ++++++++++----- tests/test_deployment_runtime_config.py | 2 +- 5 files changed, 91 insertions(+), 11 deletions(-) diff --git a/frontend/app/api/cities/detail-batch/route.ts b/frontend/app/api/cities/detail-batch/route.ts index 80e1409f..8588b43a 100644 --- a/frontend/app/api/cities/detail-batch/route.ts +++ b/frontend/app/api/cities/detail-batch/route.ts @@ -11,6 +11,38 @@ const DETAIL_BATCH_PROXY_TIMEOUT_MS = Number( process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "15000", ); +function parseRequestedCities(req: NextRequest) { + const requestedLimit = Number(req.nextUrl.searchParams.get("limit") || "12"); + const limit = Number.isFinite(requestedLimit) + ? Math.max(1, Math.min(24, requestedLimit)) + : 12; + const seen = new Set(); + const requestedCities: string[] = []; + + for (const item of (req.nextUrl.searchParams.get("cities") || "").split( + ",", + )) { + const city = item.trim(); + if (!city || seen.has(city)) continue; + seen.add(city); + requestedCities.push(city); + if (requestedCities.length >= limit) break; + } + + return requestedCities; +} + +function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) { + return { + cities: requestedCities, + details: {}, + errors: {}, + missing: requestedCities, + partial: true, + timeout: true, + }; +} + export async function GET(req: NextRequest) { const timer = createProxyTimer(req, "city_detail_batch"); if (!API_BASE) { @@ -25,6 +57,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 searchParams = new URLSearchParams({ cities: req.nextUrl.searchParams.get("cities") || "", @@ -52,6 +85,11 @@ export async function GET(req: NextRequest) { publicMessage: "Failed to fetch city detail batch", revalidateSeconds: cachePolicy.revalidateSeconds, signal: controller.signal, + timeoutResponse: () => + NextResponse.json(buildCityDetailBatchTimeoutPayload(requestedCities), { + headers: { "Cache-Control": "no-store, max-age=0" }, + status: 200, + }), timeoutPublicMessage: "City detail batch request timed out", timing: timer, url: `${API_BASE}/api/cities/detail-batch?${searchParams.toString()}`, diff --git a/frontend/app/api/scan/terminal/route.ts b/frontend/app/api/scan/terminal/route.ts index 0d6b13d2..956f6e30 100644 --- a/frontend/app/api/scan/terminal/route.ts +++ b/frontend/app/api/scan/terminal/route.ts @@ -9,7 +9,7 @@ import { const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number( - process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "18000", + process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "35000", ); export const maxDuration = 45; diff --git a/frontend/components/ops/__tests__/apiPerformanceTiming.test.ts b/frontend/components/ops/__tests__/apiPerformanceTiming.test.ts index 150c1a69..49612b4c 100644 --- a/frontend/components/ops/__tests__/apiPerformanceTiming.test.ts +++ b/frontend/components/ops/__tests__/apiPerformanceTiming.test.ts @@ -56,6 +56,26 @@ export function runTests() { /cacheControlForData/, "city detail batch proxy should be able to suppress response caching for partial payloads", ); + assert.match( + detailBatchProxy, + /buildCityDetailBatchTimeoutPayload/, + "city detail batch proxy should degrade proxy timeouts into the same partial payload shape the chart client already tolerates", + ); + assert.match( + detailBatchProxy, + /missing:\s*requestedCities/, + "city detail batch proxy timeout fallback should mark requested cities as missing instead of surfacing a browser 504", + ); + assert.match( + detailBatchProxy, + /partial:\s*true/, + "city detail batch proxy timeout fallback should preserve partial response semantics", + ); + assert.match( + detailBatchProxy, + /status:\s*200/, + "city detail batch proxy timeout fallback should avoid red 504 fetch failures for optional chart enrichment", + ); assert.match( apiProxySource, /cacheControlForData\?:/, @@ -65,6 +85,16 @@ export function runTests() { const scanTerminalProxy = readFrontend("app", "api", "scan", "terminal", "route.ts"); assert.match(scanTerminalProxy, /createProxyTimer\(req,\s*"scan_terminal"\)/); assert.match(scanTerminalProxy, /timing:\s*timer/); + assert.match( + scanTerminalProxy, + /POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS\s*\|\|\s*"35000"/, + "scan terminal proxy should allow the production backend enough time to return before the 45 second route cap", + ); + assert.match( + scanTerminalProxy, + /export const maxDuration = 45/, + "scan terminal proxy timeout budget should remain below the Next route execution cap", + ); const cityDetailProxy = readFrontend("app", "api", "city", "[name]", "detail", "route.ts"); assert.match(cityDetailProxy, /createProxyTimer\(req,\s*"city_detail"\)/); diff --git a/frontend/lib/api-proxy.ts b/frontend/lib/api-proxy.ts index bcb47596..2f7c4598 100644 --- a/frontend/lib/api-proxy.ts +++ b/frontend/lib/api-proxy.ts @@ -124,6 +124,7 @@ export async function proxyBackendJsonGet( signal?: AbortSignal; statusOnException?: number; timeoutPublicMessage?: string; + timeoutResponse?: () => NextResponse; timing?: ProxyTimer; url: string; }, @@ -195,16 +196,27 @@ export async function proxyBackendJsonGet( : withCookies; } catch (error) { const timedOut = options.signal?.aborted === true; - const response = buildProxyExceptionResponse(error, { - publicMessage: - timedOut && options.timeoutPublicMessage - ? options.timeoutPublicMessage - : options.publicMessage, - status: timedOut ? 504 : options.statusOnException, - }); - const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response; + const response = + timedOut && options.timeoutResponse + ? options.timeoutResponse() + : buildProxyExceptionResponse(error, { + publicMessage: + timedOut && options.timeoutPublicMessage + ? options.timeoutPublicMessage + : options.publicMessage, + status: timedOut ? 504 : options.statusOnException, + }); + const withCookies = auth + ? applyAuthResponseCookies(response, auth.response) + : response; + const outcome = + timedOut && options.timeoutResponse + ? "timeout_fallback" + : timedOut + ? "timeout" + : "exception"; return timing - ? finishProxyTimedResponse(withCookies, timing, timedOut ? "timeout" : "exception") + ? finishProxyTimedResponse(withCookies, timing, outcome) : withCookies; } } diff --git a/tests/test_deployment_runtime_config.py b/tests/test_deployment_runtime_config.py index 2519c606..3f7fc425 100644 --- a/tests/test_deployment_runtime_config.py +++ b/tests/test_deployment_runtime_config.py @@ -70,7 +70,7 @@ def test_scan_terminal_backend_timeout_returns_before_next_proxy_abort(): ROOT / "web" / "services" / "scan_terminal_config.py" ).read_text(encoding="utf-8") - assert 'POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "18000"' in route_source + assert 'POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "35000"' in route_source assert '"POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC",\n 10,' in config_source assert ( '"POLYWEATHER_SCAN_TERMINAL_PREWARM_PAYLOAD_TIMEOUT_SEC",\n 30,'