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
+39 -15
View File
@@ -15,7 +15,16 @@ const todayCache = new TtlCache<TodayCacheValue>(5 * 60 * 1000);
// doesn't re-fetch on every request. Same TTL as todayCache so they expire
// together.
const todayChecked = new TtlCache<true>(5 * 60 * 1000);
const ridesCache = new TtlCache<{ openRides: number; openCoasters: number } | null>(5 * 60 * 1000);
// "ok" — fresh fetch succeeded; counts reflect actual live data.
// "unknown" — fetch failed (network, timeout, rate-limit, upstream null).
// We do NOT know whether the park is in weather delay; treat as
// "no signal" so the homepage doesn't falsely flag it. Stored with
// a shorter TTL so we recover quickly when upstream comes back.
type RidesCacheEntry =
| { kind: "ok"; openRides: number; openCoasters: number }
| { kind: "unknown" };
const ridesCache = new TtlCache<RidesCacheEntry>(5 * 60 * 1000);
const UNKNOWN_RIDES_TTL_MS = 30_000;
const app = new Hono();
@@ -89,29 +98,44 @@ app.get("/week", async (c) => {
const trackedParks = openTodayParks.filter((p) => QUEUE_TIMES_IDS[p.id]);
const results = await Promise.all(
trackedParks.map(async (p) => {
let cached = ridesCache.get(p.id);
if (cached === null) {
trackedParks.map(async (p): Promise<{ id: string; entry: RidesCacheEntry }> => {
let entry = ridesCache.get(p.id);
if (!entry) {
const coasterSet = getCoasterSet(p.id);
const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], coasterSet).catch((err: Error) => {
log.warn("calendar.week", "fetchLiveRides failed", { park: p.id, err: err.message });
return null;
});
cached = result
? {
openRides: result.rides.filter((r) => r.isOpen).length,
openCoasters: result.rides.filter((r) => r.isOpen && r.isCoaster).length,
}
: null;
ridesCache.set(p.id, cached);
if (result) {
entry = {
kind: "ok",
openRides: result.rides.filter((r) => r.isOpen).length,
openCoasters: result.rides.filter((r) => r.isOpen && r.isCoaster).length,
};
ridesCache.set(p.id, entry);
} else {
entry = { kind: "unknown" };
ridesCache.set(p.id, entry, UNKNOWN_RIDES_TTL_MS);
}
}
return { id: p.id, ...(cached ?? { openRides: 0, openCoasters: 0 }) };
return { id: p.id, entry };
}),
);
weatherDelayParkIds = results.filter(({ openRides }) => openRides === 0).map(({ id }) => id);
rideCounts = Object.fromEntries(results.filter(({ openRides }) => openRides > 0).map(({ id, openRides }) => [id, openRides]));
coasterCounts = Object.fromEntries(results.filter(({ openCoasters }) => openCoasters > 0).map(({ id, openCoasters }) => [id, openCoasters]));
// Only flag weather delay when we know rides are actually closed. An
// "unknown" entry means our upstream fetch failed — claim no badge rather
// than falsely showing the storm icon for an outage.
for (const { id, entry } of results) {
if (entry.kind !== "ok") continue;
if (entry.openRides === 0) {
weatherDelayParkIds.push(id);
} else {
rideCounts[id] = entry.openRides;
}
if (entry.openCoasters > 0) {
coasterCounts[id] = entry.openCoasters;
}
}
}
const scrapedCount = Object.values(data).reduce((sum, parkData) => sum + Object.keys(parkData).length, 0);
+1
View File
@@ -72,6 +72,7 @@ export function startScheduler(): void {
const r = await sampleAllOpenParks();
log.info("scheduler.tier5", "sample run complete", {
parksSampled: r.parksSampled,
parksSkipped: r.parksSkipped,
samplesWritten: r.samplesWritten,
weatherDelayed: r.weatherDelayed,
errors: r.errors,