koala73/worldmonitor · error · Error

EONET malformed response

Error message

EONET malformed response

What it means

After a successful (2xx) EONET response, fetchEonet parses JSON and validates the envelope: `if (!Array.isArray(data?.events)) throw new Error('EONET malformed response')`. This fires when EONET answers 200 but the body is not the expected `{ events: [...] }` shape — typically an HTML login/error page served with 200, an empty/HTML proxy response, or an EONET schema change. It protects the seed loop (which iterates data.events and reads event.categories) from undefined-property crashes.

Solutions

  1. Log the first ~200 chars of the raw body on this error to see whether it is HTML (proxy/portal) or JSON with a different schema.
  2. Verify EONET_API_URL points at the current EONET v3 endpoint (`https://eonet.gsfc.nasa.gov/api/v3/events`) and that the env/config value is not overridden.
  3. If HTML with status 200, fix the network path (proxy exceptions, no captive portal) rather than the code.
  4. Keep the guard: it is correct — optionally widen it to also reject an empty non-object body and include a body snippet in the message for diagnosability.

Example fix

// before
const data = await res.json();
if (!Array.isArray(data?.events)) throw new Error('EONET malformed response');
// after
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch { /* fallthrough */ }
if (!Array.isArray(data?.events)) {
  throw new Error(`EONET malformed response: ${text.slice(0, 200)}`);
}
Defensive patterns

Strategy: validation

Validate before calling

// Check the body shape before consuming events:
const data = await res.json();
const eventsIsArray = data != null && typeof data === 'object' && Array.isArray(data.events);
if (!eventsIsArray) console.warn('EONET 200 response does not contain an events array — check proxy/endpoint');

Type guard

function isEonetPayload(data) {
  return data != null && typeof data === 'object' && Array.isArray(data.events);
}

Try / catch

try {
  const events = await fetchEonet(days);
} catch (err) {
  if (err.message === 'EONET malformed response') {
    // 200 but wrong shape: log raw body snippet, treat as degraded rather than crash
    logDegraded('eonet', 'unexpected 200 payload');
  } else throw err;
}

Prevention

When it happens

Trigger: EONET returns HTTP 200 whose JSON body lacks an `events` array: captive-portal/proxy HTML interstitial (JSON.parse may even throw first), API version drift renaming/moving `events`, or a `data` object where events is null/string instead of an array.

Common situations: Corporate proxy or Wi-Fi captive portal injecting a 200 HTML page; hitting an EONET endpoint URL that changed (stale EONET_API_URL env/config); NASA altering the response envelope in a new API version.

Understand the failure class

Related errors


AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15). Data as JSON: /api/errors/81112609429044b2. Report an issue: GitHub.

Appendix: source

Thrown at scripts/seed-natural-events.mjs:98

  'landslides', 'drought', 'dustHaze', 'snow', 'tempExtremes',
  'seaLakeIce', 'waterColor', 'manmade',
]);

function normalizeCategory(id) {
  const c = String(id || '').trim();
  return NATURAL_EVENT_CATEGORIES.has(c) ? c : 'manmade';
}

async function fetchEonet(days, fetchFn = globalThis.fetch) {
  const url = `${EONET_API_URL}?status=open&days=${days}`;
  const res = await fetchFn(url, {
    headers: { Accept: 'application/json', 'User-Agent': CHROME_UA },
    signal: AbortSignal.timeout(15_000),
  });
  if (!res.ok) throw new Error(`EONET ${res.status}`);

  const data = await res.json();
  if (!Array.isArray(data?.events)) throw new Error('EONET malformed response');
  const events = [];
  const now = Date.now();

  for (const event of data.events || []) {
    const category = event.categories?.[0];
    if (!category) continue;
    const normalizedCategory = normalizeCategory(category.id);
    if (normalizedCategory === 'earthquakes') continue;

    const latestGeo = event.geometry?.[event.geometry.length - 1];
    if (!latestGeo || latestGeo.type !== 'Point') continue;

    const eventDate = new Date(latestGeo.date);
    const [lon, lat] = latestGeo.coordinates;

    if (normalizedCategory === 'wildfires' && now - eventDate.getTime() > WILDFIRE_MAX_AGE_MS) continue;

    const source = event.sources?.[0];

View on GitHub (pinned to 7d06c8633d)