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
- Retry once after a short delay — this is typically a transient gateway/proxy issue.
- Inspect the payload field (first 200 chars) in the error to identify whether it's HTML (proxy/gateway) or malformed JSON (protocol change).
- If persistent, the iLink backend may have changed — the protocol is reverse-engineered and undocumented.
- 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
- Inspect ApiError.payload (first 200 chars of body) to distinguish HTML gateway pages from protocol changes.
- For long-poll getUpdates, rely on runLoop's exponential backoff (WeChatProtocol.ts:919) for transient gateway errors.
- Ensure network egress to *.weixin.qq.com is not through an HTML-injecting proxy.
- Log the endpoint label and status together — a single endpoint returning non-JSON suggests a route-specific issue.
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
- ${label} failed with HTTP ${response.status}
- ${label} failed
- Slack API error ${method}: HTTP ${response.status} - ${error
- QR login confirmed, but the API did not return bot credentia
- Failed to upload image to WeChat CDN
AI-assisted analysis of CherryHQ/cherry-studio@726446b54c (2026-08-12).
Data as JSON: /api/errors/83c222ae128e49fc.
Report an issue: GitHub.