koala73/worldmonitor · error · NhcQueryError

bodyTransportFailure ? 'NHC_POINT_REQUEST_FAILED' : 'NHC_POINT_RESPONSE_INVALID'

bodyTransportFailure ? 'NHC_POINT_REQUEST_FAILED' : 'NHC_POINT_RESPONSE_INVALID'

Error message

NHC layer ${layerId} ${bodyTransportFailure ? 'body read failed' : 'returned invalid JSON'}

What it means

After a successful HTTP status, nhcQuery calls res.json(); if that throws, it classifies the failure: TypeError, AbortError, or TimeoutError mean the body stream broke mid-read (body read failed -> NHC_POINT_REQUEST_FAILED, retryable), while any other parse error means the body was complete but not valid JSON (returned invalid JSON -> NHC_POINT_RESPONSE_INVALID, nonRetryable). This distinction lets withRetry retry truncated transfers but not deterministic bad payloads.

Solutions

  1. Capture the raw body text (res.text() then JSON.parse) on failure to see whether it is HTML, empty, or truncated JSON.
  2. For TimeoutError/AbortError, increase the timeout beyond 15 s or reduce the requested payload (filter outFields, add resultRecordCount).
  3. For invalid JSON, check for proxy/interception returning HTML with 200 and bypass or allowlist the NHC host.
  4. For transport failures, rely on the retryable NHC_POINT_REQUEST_FAILED path and re-run; add resume/backoff for flaky links.
  5. Verify content-encoding handling — if the environment cannot decompress gzip, send Accept-Encoding: identity.

Example fix

// before
payload = await res.json();
// after: capture raw text to diagnose non-JSON bodies
const text = await res.text();
try {
  payload = JSON.parse(text);
} catch (cause) {
  if (text.trimStart().startsWith('<')) {
    throw new NhcQueryError(`NHC layer ${layerId} returned HTML instead of JSON`, { code: 'NHC_POINT_RESPONSE_INVALID', cause, nonRetryable: true });
  }
  throw cause;
}
Defensive patterns

Strategy: try-catch

Validate before calling

const text = await res.text();
if (!text.trim()) throw new Error('empty body');
if (text.trimStart()[0] !== '{' && text.trimStart()[0] !== '[') throw new Error('non-JSON body (HTML/interstitial page)');
const payload = JSON.parse(text); // throws SyntaxError with position info for truncated JSON

Type guard

function isTransportBodyFailure(cause) {
  return cause instanceof TypeError || cause?.name === 'AbortError' || cause?.name === 'TimeoutError';
}

Try / catch

try {
  payload = await res.json();
} catch (cause) {
  if (cause?.name === 'TimeoutError' || cause?.name === 'AbortError' || cause instanceof TypeError) {
    throw new NhcQueryError(`NHC layer ${layerId} body read failed`, { code: 'NHC_POINT_REQUEST_FAILED', cause, nonRetryable: false });
  }
  const text = await res.clone?.().text?.().catch(() => '') ?? '';
  logger.error({ layerId, preview: text.slice(0, 200) }, 'NHC returned non-JSON body');
  throw new NhcQueryError(`NHC layer ${layerId} returned invalid JSON`, { code: 'NHC_POINT_RESPONSE_INVALID', cause, nonRetryable: true });
}

Prevention

When it happens

Trigger: The NHC layer query returns a 200 whose body is HTML (an error/interstitial page), an empty body, truncated gzip/deflate content, or a connection reset mid-stream; or an AbortSignal.timeout(15_000) fires while the body is still streaming, surfacing as AbortError/TimeoutError inside res.json().

Common situations: A proxy/CDN serves an HTML block page with status 200; slow NHC responses exceed the 15 s timeout on large FeatureCollections; the edge closes the connection early on big layer downloads; content-encoding mismatch makes the body undecodable.

Understand the failure class

Background: "Invalid JSON response" and "Failed to parse response" errors: when an API answers 200 but the body isn't the JSON your library expected — this error's family across 28 libraries.

Related errors


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

Appendix: source

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

      headers: { Accept: 'application/json', 'User-Agent': CHROME_UA },
      signal: AbortSignal.timeout(15_000),
    });
    if (!res.ok) {
      await res.body?.cancel?.();
      const cause = httpRetryError(res, { remainingBudgetMs: 15_000 });
      throw new NhcQueryError(`NHC layer ${layerId}: ${cause.message}`, {
        cause,
        nonRetryable: cause.nonRetryable,
      });
    }
    let payload;
    try {
      payload = await res.json();
    } catch (cause) {
      const bodyTransportFailure = cause instanceof TypeError
        || cause?.name === 'AbortError'
        || cause?.name === 'TimeoutError';
      throw new NhcQueryError(
        `NHC layer ${layerId} ${bodyTransportFailure ? 'body read failed' : 'returned invalid JSON'}`,
        {
          code: bodyTransportFailure ? 'NHC_POINT_REQUEST_FAILED' : 'NHC_POINT_RESPONSE_INVALID',
          cause,
          nonRetryable: !bodyTransportFailure,
        },
      );
    }
    return parseNhcGeoJson(payload, layerId, expectedGeometryTypes);
  }, 1, 500);
}

const NHC_STORM_TYPES = {
  HU: 'Hurricane', TS: 'Tropical Storm', TD: 'Tropical Depression',
  STS: 'Subtropical Storm', STD: 'Subtropical Depression',
  EX: 'Post-Tropical', PT: 'Post-Tropical',
};

View on GitHub (pinned to 7d06c8633d)