fix: surface silent scraper failures and stop falsely claiming weather delay
Build and Deploy / Lint, typecheck, test (push) Successful in 33s
Build and Deploy / Build & Push (push) Successful in 1m4s

The homepage was flagging every park as weather delay because calendar.ts
collapsed "fetchLiveRides returned null" into the same openRides=0 bucket as
"all rides actually closed." Meanwhile every scraper (queuetimes, sixflags
operating-hours, sixflags wait-times) was swallowing non-OK responses and
exceptions silently, so logs gave no signal which upstream was failing or how.

Add a small scraperWarn helper that emits in the same shape as backend/log.ts
(without importing it — lib/scrapers is shared with the Next frontend). Use it
in all three scrapers to record HTTP status and error name+message before each
return null. Add parksSkipped to the tier-5 summary log so we can tell when the
openParks filter is rejecting everyone vs the fetcher silently failing.

Convert calendar.ts ridesCache to a discriminated union { kind: "ok" | "unknown" }.
Weather delay only fires on { kind: "ok", openRides: 0 }; unknown entries get
a 30s TTL so we recover quickly when upstream comes back and don't thunder-herd
in the meantime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 20:28:25 -04:00
parent d43d8eba86
commit e1657f07d7
6 changed files with 129 additions and 25 deletions
+35 -6
View File
@@ -7,6 +7,8 @@
* Rate limiting: on 429/503, exponential backoff (30s → 60s → 120s), MAX_RETRIES attempts.
*/
import { scraperWarn } from "./log";
const API_BASE = "https://d18car1k0ff81h.cloudfront.net/operating-hours/park";
const MAX_RETRIES = 3;
const BASE_BACKOFF_MS = 30_000;
@@ -191,9 +193,18 @@ export async function fetchToday(apiId: number, revalidate?: number): Promise<Da
try {
const url = `${API_BASE}/${apiId}`;
const raw = await fetchApi(url, 0, 0, revalidate);
if (!raw.dates.length) return null;
if (!raw.dates.length) {
scraperWarn("sixflags", "fetchToday empty dates array", { apiId });
return null;
}
return parseApiDay(raw.dates[0]);
} catch {
} catch (err) {
const e = err as Error;
scraperWarn("sixflags", "fetchToday threw", {
apiId,
name: e.name,
err: e.message,
});
return null;
}
}
@@ -224,11 +235,22 @@ export async function scrapeRidesForDay(
let raw: ApiResponse;
try {
raw = await scrapeMonthRaw(apiId, year, month, revalidate);
} catch {
} catch (err) {
const e = err as Error;
scraperWarn("sixflags", "scrapeRidesForDay scrapeMonthRaw threw", {
apiId,
year,
month,
name: e.name,
err: e.message,
});
return null;
}
if (!raw.dates.length) return null;
if (!raw.dates.length) {
scraperWarn("sixflags", "scrapeRidesForDay empty dates array", { apiId, year, month });
return null;
}
// The API uses "MM/DD/YYYY" internally.
const [, mm, dd] = dateIso.split("-");
@@ -260,8 +282,15 @@ export async function scrapeRidesForDay(
const nextRaw = await scrapeMonthRaw(apiId, nextYear, nextMonth, revalidate);
const nextSorted = [...nextRaw.dates].sort((a, b) => a.date.localeCompare(b.date));
dayData = nextSorted.find((d) => !d.isParkClosed) ?? nextSorted[0];
} catch {
// If the next month fetch fails, we simply have no fallback data.
} catch (err) {
const e = err as Error;
scraperWarn("sixflags", "scrapeRidesForDay next-month fallback threw", {
apiId,
year: nextYear,
month: nextMonth,
name: e.name,
err: e.message,
});
}
}