{"record":{"id":"81112609429044b2","repo":"koala73/worldmonitor","slug":"eonet-malformed-response","errorCode":null,"errorMessage":"EONET malformed response","messagePattern":"EONET malformed response","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"scripts/seed-natural-events.mjs","lineNumber":248,"sourceCode":"          details = ' details={\"unavailable\":true}';\n        }\n      }\n      const error = Object.assign(new Error(`${source} ${stage} ${kind} attempt=${attempt} elapsedMs=${Math.round(finished - started)} attemptElapsedMs=${Math.round(finished - attemptStarted)}${phaseTimings}${details}`), {\n        nonRetryable: stage === 'http' ? cause.nonRetryable : !transport,\n        retryAfterMs: cause.retryAfterMs,\n      });\n      if (deadline - performance.now() <= Math.max(500, error.retryAfterMs || 0)) error.nonRetryable = true;\n      throw error;\n    } finally {\n      await dispatcher?.destroy();\n    }\n  }, 1, 500);\n}\n\nasync function fetchEonet(days, fetchFn = globalThis.fetch, now = Date.now()) {\n  const url = `${EONET_API_URL}?status=open&days=${days}`;\n  const data = await fetchEventSourceJson('eonet', url, fetchFn);\n  if (!Array.isArray(data?.events)) throw new Error('EONET malformed response');\n  const events = [];\n\n  for (const event of data.events || []) {\n    const category = event.categories?.[0];\n    if (!category) throw new Error('EONET malformed event category');\n    const normalizedCategory = normalizeCategory(category.id);\n    if (normalizedCategory === 'earthquakes') continue;\n\n    const latestGeo = event.geometry?.[event.geometry.length - 1];\n    if (!latestGeo || !['Point', 'Polygon'].includes(latestGeo.type) || !Array.isArray(latestGeo.coordinates)) {\n      throw new Error('EONET malformed event geometry');\n    }\n    if (latestGeo.type !== 'Point') continue;\n\n    const eventDate = new Date(latestGeo.date);\n    const [lon, lat] = latestGeo.coordinates;\n\n    if (normalizedCategory === 'wildfires' && now - eventDate.getTime() > WILDFIRE_MAX_AGE_MS) continue;","sourceCodeStart":230,"sourceCodeEnd":266,"githubUrl":"https://github.com/koala73/worldmonitor/blob/e586b8b4b80f595aa7ece295eec10d76f2921240/scripts/seed-natural-events.mjs#L230-L266","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["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.","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.","If HTML with status 200, fix the network path (proxy exceptions, no captive portal) rather than the code.","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."],"exampleFix":"// before\nconst data = await res.json();\nif (!Array.isArray(data?.events)) throw new Error('EONET malformed response');\n// after\nconst text = await res.text();\nlet data;\ntry { data = JSON.parse(text); } catch { /* fallthrough */ }\nif (!Array.isArray(data?.events)) {\n  throw new Error(`EONET malformed response: ${text.slice(0, 200)}`);\n}","handlingStrategy":"validation","validationCode":"// Check the body shape before consuming events:\nconst data = await res.json();\nconst eventsIsArray = data != null && typeof data === 'object' && Array.isArray(data.events);\nif (!eventsIsArray) console.warn('EONET 200 response does not contain an events array — check proxy/endpoint');","typeGuard":"function isEonetPayload(data) {\n  return data != null && typeof data === 'object' && Array.isArray(data.events);\n}","tryCatchPattern":"try {\n  const events = await fetchEonet(days);\n} catch (err) {\n  if (err.message === 'EONET malformed response') {\n    // 200 but wrong shape: log raw body snippet, treat as degraded rather than crash\n    logDegraded('eonet', 'unexpected 200 payload');\n  } else throw err;\n}","preventionTips":["Pin and periodically verify EONET_API_URL against the current v3 API; a stale URL can return an HTML page.","Log a snippet of the raw body on validation failure to distinguish proxy HTML from schema drift.","Beware captive portals/proxies in CI networks that answer 200 with HTML.","Subscribe to EONET/NASA service notices for envelope changes before they break the seed."],"tags":["schema","eonet","nasa","seed-script","response-validation"],"backgroundTag":"unexpected-response-shape","analyzedSha":"e586b8b4b80f595aa7ece295eec10d76f2921240","analyzedAt":"2026-09-22T01:50:49.965Z","contentChangedAt":"2026-09-22T01:50:49.965Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}