jackwener/OpenCLI · error · LoginWallError

Server returned HTML instead of JSON (status=${response.stat

Error message

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

What it means

parseJsonOrThrowLoginWall checks the response content-type and the first characters of the body; if either indicates HTML, it throws LoginWallError (code LOGIN_WALL, exit 77) carrying the HTTP status, URL, and first 100 chars of the body. It exists to convert 'server gave me a webpage' into an actionable auth/rate-limit error rather than a JSON parse stack trace.

Source

Thrown at src/utils.ts:148

 * 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. */
export async function parseJsonOrThrowLoginWall(
  response: Response,
  opts: { url?: string } = {},
): Promise<unknown> {
  const contentType = response.headers.get('content-type') || '';
  const text = await response.text();
  const trimmed = text.trimStart();

  const looksLikeHtml =
    contentType.toLowerCase().includes('text/html')
    || /^<(?:!doctype|html|head|body|title)(?:[\s>/]|$)/i.test(trimmed);

  if (looksLikeHtml) {
    throw new LoginWallError(
      `Server returned HTML instead of JSON (status=${response.status}). `
      + `Likely a login wall, rate limit, or WAF challenge.`,
      response.status,
      opts.url || response.url || '',
      trimmed.slice(0, 100),
    );
  }

  try {
    return JSON.parse(text);
  } catch (err) {
    // Real malformed JSON \u2014 surface body preview alongside the parser message
    // so we don't have to repro to know what came back.
    throw new Error(
      `JSON parse failed (status=${response.status}, body[0..50]=${JSON.stringify(trimmed.slice(0, 50))}): `
      + (err instanceof Error ? err.message : String(err)),
    );
  }

View on GitHub (pinned to 49907e53dc)

Solutions

  1. Log in again in the browser session the CLI uses, then retry
  2. If status is 429 or the body mentions challenge/verify, back off and retry after a delay
  3. Verify the URL is the JSON API endpoint, not an HTML page that redirects
  4. Inspect err.bodyPreview to identify the wall type before automating retries

Example fix

// before
const data = await res.json();
// after
const data = await parseJsonOrThrowLoginWall(res, { url });
Defensive patterns

Strategy: try-catch

Validate before calling

const ct = res.headers.get('content-type') || '';
if (ct.includes('text/html')) throw new LoginWallError('HTML response', res.status, res.url, (await res.text()).slice(0, 100));

Type guard

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

Try / catch

try {
  const data = await parseJsonOrThrowLoginWall(res, { url });
} catch (e) {
  if (e instanceof LoginWallError) {
    if (e.status === 429) await sleep(60_000);
    else await relogin();
  } else throw e;
}

Prevention

When it happens

Trigger: Calling parseJsonOrThrowLoginWall on a fetch whose response content-type includes 'text/html' or whose trimmed body starts with <!doctype/html/head/body/title — e.g. an API URL that redirected to a login page, or a 403/429 WAF page.

Common situations: Expired session cookies on sites like x.com/grok; scraping an endpoint that now requires auth; corporate proxies injecting HTML error pages; Cloudflare 'checking your browser' interstitials during bursts of requests.

Related errors


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