CherryHQ/cherry-studio · error · ApiError

${label} returned non-JSON (HTTP ${response.status})

Error message

${label} returned non-JSON (HTTP ${response.status})

What it means

Thrown as an ApiError by parseJsonResponse() in WeChatProtocol when the HTTP response body cannot be JSON.parse'd. This is the WeChat iLink backend (ilinkai.weixin.qq.com) returning a non-JSON body — an HTML error page, an empty body, or a gateway/proxy interstitial. The label is the endpoint path (e.g. '/ilink/bot/getupdates', '/ilink/bot/sendmessage'). The error carries status and a payload of the first 200 chars of the body for diagnosis.

Source

Thrown at src/main/ai/channels/adapters/wechat/WeChatProtocol.ts:381

    super(message)
    this.name = 'ApiError'
    this.status = options.status
    this.code = options.code
    this.payload = options.payload
  }
}

function buildBaseInfo(): BaseInfo {
  return { channel_version: CHANNEL_VERSION }
}

async function parseJsonResponse(response: Response, label: string): Promise<unknown> {
  const text = await response.text()
  let raw: unknown
  try {
    raw = text ? JSON.parse(text) : {}
  } catch {
    throw new ApiError(`${label} returned non-JSON (HTTP ${response.status})`, {
      status: response.status,
      payload: text.slice(0, 200)
    })
  }

  if (!response.ok) {
    const body = ApiErrorBodySchema.safeParse(raw)
    const parsed = body.success ? body.data : {}
    throw new ApiError(parsed.errmsg ?? `${label} failed with HTTP ${response.status}`, {
      status: response.status,
      code: parsed.errcode,
      payload: raw
    })
  }

  const body = ApiErrorBodySchema.safeParse(raw)
  if (body.success && typeof body.data.ret === 'number' && body.data.ret !== 0) {
    throw new ApiError(body.data.errmsg ?? `${label} failed`, {

View on GitHub (pinned to 726446b54c)

Solutions

  1. Retry once after a short delay — this is typically a transient gateway/proxy issue.
  2. Inspect the payload field (first 200 chars) in the error to identify whether it's HTML (proxy/gateway) or malformed JSON (protocol change).
  3. If persistent, the iLink backend may have changed — the protocol is reverse-engineered and undocumented.
  4. Ensure network egress to *.weixin.qq.com is not intercepted by an HTML-injecting proxy.

Example fix

// before — single fetch, throws on any non-JSON gateway blip
const raw = await apiFetch(baseUrl, '/ilink/bot/getupdates', body, token, uin, 40_000, signal)

// after — one bounded retry for transient gateway HTML
async function apiFetchWithRetry(...): Promise<unknown> {
  try {
    return await apiFetch(...)
  } catch (e) {
    if (e instanceof ApiError && e.status >= 500) {
      await delay(1_000)
      return await apiFetch(...)
    }
    throw e
  }
}
Defensive patterns

Strategy: retry

Validate before calling

// You cannot prevent a gateway from returning HTML, but you can bound the retry.
// Wrap the WeChat API call in a single-retry helper for transient 5xx/non-JSON:
async function fetchWeChatJsonWithRetry<T>(
  fn: () => Promise<T>,
  retries = 1
): Promise<T> {
  try {
    return await fn()
  } catch (e) {
    if (e instanceof ApiError && e.status >= 500 && retries > 0) {
      await delay(1_000)
      return await fn()
    }
    throw e
  }
}

Type guard

// Distinguish a non-JSON ApiError from other ApiError variants
function isNonJsonApiError(e: unknown): boolean {
  return (
    e instanceof ApiError &&
    /returned non-JSON/.test(e.message)
  )
}

function isTransientGatewayError(e: unknown): boolean {
  return isNonJsonApiError(e) && (e as ApiError).status >= 500
}

Try / catch

try {
  const updates = await getUpdates(baseUrl, token, uin, cursor, signal)
} catch (e) {
  if (isTransientGatewayError(e)) {
    // runLoop already backs off with retryDelayMs; let it handle this
    logger.warn('WeChat gateway returned non-JSON, will retry', { status: (e as ApiError).status })
    return // runLoop's catch block increments retryDelayMs
  }
  if (isNonJsonApiError(e)) {
    // Persistent non-JSON from a 2xx/4xx — likely a protocol change or proxy
    logger.error('WeChat endpoint returned non-JSON unexpectedly', { payload: (e as ApiError).payload })
  }
  throw e
}

Prevention

When it happens

Trigger: Any apiFetch() or apiGet() call whose response body fails JSON.parse: a reverse proxy returns an HTML 502/504 page; Cloudflare/WAF challenge HTML; an empty 200 body (caught by the `text ? JSON.parse(text) : {}` guard, so empty bodies do NOT trigger this — only non-empty non-JSON does); the CDN endpoint (novac2c.cdn.weixin.qq.com) is not used here since CDN responses are raw binary, not parsed by parseJsonResponse.

Common situations: WeChat iLink backend is having an outage and returning nginx HTML error pages; a corporate proxy injects an HTML block page; the reverse-engineered protocol changed and the server now returns a different content type; rare transient gateway failures.

Related errors


AI-assisted analysis of CherryHQ/cherry-studio@726446b54c (2026-08-12). Data as JSON: /api/errors/83c222ae128e49fc. Report an issue: GitHub.