diff --git a/frontend/app/api/scan/terminal/route.ts b/frontend/app/api/scan/terminal/route.ts index 553f6bf4..bb1c4ccd 100644 --- a/frontend/app/api/scan/terminal/route.ts +++ b/frontend/app/api/scan/terminal/route.ts @@ -5,6 +5,12 @@ import { } from "@/lib/backend-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; +const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number( + process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "28000", +); + +export const dynamic = "force-dynamic"; +export const maxDuration = 30; export async function GET(req: NextRequest) { if (!API_BASE) { @@ -35,11 +41,16 @@ export async function GET(req: NextRequest) { const url = `${API_BASE}/api/scan/terminal?${params.toString()}`; + let auth: Awaited> | null = null; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), SCAN_TERMINAL_PROXY_TIMEOUT_MS); + try { - const auth = await buildBackendRequestHeaders(req); + auth = await buildBackendRequestHeaders(req); const res = await fetch(url, { headers: auth.headers, cache: "no-store", + signal: controller.signal, }); if (!res.ok) { const raw = await res.text(); @@ -57,9 +68,18 @@ export async function GET(req: NextRequest) { }); return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( - { error: "Failed to fetch scan terminal data", detail: String(error) }, - { status: 500 }, + const timedOut = controller.signal.aborted; + const response = NextResponse.json( + { + error: timedOut + ? "Scan terminal request timed out" + : "Failed to fetch scan terminal data", + detail: String(error), + }, + { status: timedOut ? 504 : 500 }, ); + return auth ? applyAuthResponseCookies(response, auth.response) : response; + } finally { + clearTimeout(timeoutId); } } diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts index 7ae0830b..7facfb0c 100644 --- a/frontend/lib/dashboard-client.ts +++ b/frontend/lib/dashboard-client.ts @@ -63,6 +63,7 @@ export type AssistantChatResponse = { const CACHE_KEY = "polyWeather_v1"; const CACHE_TTL_MS = 5 * 60 * 1000; +const SCAN_TERMINAL_CLIENT_TIMEOUT_MS = 35_000; const pendingCityDetailRequests = new Map>(); const pendingHistoryRequests = new Map>(); const pendingCitySummaryRequests = new Map>(); @@ -99,11 +100,30 @@ function normalizeDetailDepth(depth?: "panel" | "market" | "nearby" | "full") { return "panel"; } -async function fetchJson(url: string): Promise { - const response = await fetch(url, { - headers: { Accept: "application/json" }, - cache: "no-store", - }); +async function fetchJson(url: string, options?: { timeoutMs?: number }): Promise { + const timeoutMs = options?.timeoutMs; + const controller = timeoutMs ? new AbortController() : null; + const timeoutId = controller + ? window.setTimeout(() => controller.abort(), timeoutMs) + : null; + + let response: Response; + try { + response = await fetch(url, { + headers: { Accept: "application/json" }, + cache: "no-store", + signal: controller?.signal, + }); + } catch (error) { + if (controller?.signal.aborted) { + throw new Error("Request timed out"); + } + throw error; + } finally { + if (timeoutId != null) { + window.clearTimeout(timeoutId); + } + } if (!response.ok) { throw new Error(`HTTP ${response.status}`); @@ -408,6 +428,7 @@ export const dashboardClient = { } const request = fetchJson( `/api/scan/terminal?${params.toString()}`, + { timeoutMs: SCAN_TERMINAL_CLIENT_TIMEOUT_MS }, ).finally(() => { pendingScanTerminalRequests.delete(requestKey); });