jackwener/OpenCLI · error · LoginWallError

Server returned HTML instead of JSON (status=${value.status}

Error message

Server returned HTML instead of JSON (status=${value.status}). Likely a login wall, rate limit, or WAF challenge.

What it means

throwIfLoginWall inspects a value fetched from a web endpoint and throws LoginWallError (code LOGIN_WALL, exit 77 NOPERM) when the payload looks like an HTML page rather than JSON. The library does this so a login wall / rate-limit page / WAF challenge surfaces as a typed error with status, URL, and a 100-char body preview instead of a cryptic JSON.parse SyntaxError.

Source

Thrown at src/utils.ts:116

}

function isLoginWallSignal(v: unknown): v is LoginWallSignal {
  return (
    typeof v === 'object'
    && v !== null
    && (v as Record<string, unknown>).__loginWall === true
    && typeof (v as Record<string, unknown>).status === 'number'
  );
}

/** Throw a `LoginWallError` if `value` is the sentinel returned by the
 * browser-side sniffer; otherwise return `value` unchanged. Adapters that
 * fetch from inside `page.evaluate` call this on the result before consuming
 * it, so the Node-side gets a typed error instead of a JSON-parse stack
 * trace. */
export function throwIfLoginWall<T>(value: T, opts: { url?: string } = {}): T {
  if (isLoginWallSignal(value)) {
    throw new LoginWallError(
      `Server returned HTML instead of JSON (status=${value.status}). `
      + `Likely a login wall, rate limit, or WAF challenge.`,
      value.status,
      opts.url || value.url || '',
      value.bodyPreview,
    );
  }
  return value;
}

/** Parse a `Response` body as JSON, throwing `LoginWallError` if the server
 * returned an HTML page (login wall / rate limit / WAF interception) instead
 * of the expected JSON. Catches the common case of `<!DOCTYPE` or `<html`
 * leading the body \u2014 naive `JSON.parse` on these gives a cryptic
 * `SyntaxError` that callers can't distinguish from "real" malformed JSON.
 *
 * On real (non-HTML) JSON-parse failures, throws a regular `Error` with a
 * body preview attached so debugging doesn't require a packet capture. */

View on GitHub (pinned to 49907e53dc)

Solutions

  1. Re-authenticate in the browser (the CLI relies on your logged-in browser session), then retry
  2. Wait a few minutes if rate-limited, then retry with backoff
  3. Open the error's url in a real browser to confirm whether it's a login page or WAF challenge
  4. Check err.status/err.bodyPreview on the LoginWallError to determine which case applies

Example fix

// before
const data = JSON.parse(await page.evaluate(fetchJson, url));
// after
const raw = await page.evaluate(fetchJson, url);
const data = throwIfLoginWall(JSON.parse(raw), { url });
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-flight: probe the endpoint once and check content-type before batch work
const probe = await fetch(url);
if ((probe.headers.get('content-type') || '').includes('text/html')) throw new Error('login wall detected before start');

Type guard

function isLoginWallError(e: unknown): e is LoginWallError {
  return e instanceof LoginWallError;
}

Try / catch

try {
  const data = throwIfLoginWall(raw, { url });
} catch (e) {
  if (e instanceof LoginWallError) {
    console.error(`Walled at ${e.url} (status ${e.status}): ${e.bodyPreview}. Re-login or back off.`);
  } else throw e;
}

Prevention

When it happens

Trigger: Any adapter fetch that returns an HTML response detected by isLoginWallSignal (HTML content-type or '<!doctype'/'<html>'-style start tag with an HTTP status), passed through throwIfLoginWall before consumption — including results from inside page.evaluate.

Common situations: Session cookies expired so the site serves its login page at the API URL; hitting rate limits and receiving an HTML 429 challenge; Cloudflare/WAF interstitial pages; scraping endpoints while logged out or behind a captive portal.

Related errors


AI-assisted analysis of jackwener/OpenCLI@49907e53dc (2026-08-29). Data as JSON: /api/errors/bb44e6813fbc1b63. Report an issue: GitHub.