{"record":{"id":"83c222ae128e49fc","repo":"CherryHQ/cherry-studio","slug":"label-returned-non-json-http-response-status","errorCode":null,"errorMessage":"${label} returned non-JSON (HTTP ${response.status})","messagePattern":"(.+?) returned non-JSON \\(HTTP (.+?)\\)","errorType":"exception","errorClass":"ApiError","httpStatus":null,"severity":"error","filePath":"src/main/ai/channels/adapters/wechat/WeChatProtocol.ts","lineNumber":381,"sourceCode":"    super(message)\n    this.name = 'ApiError'\n    this.status = options.status\n    this.code = options.code\n    this.payload = options.payload\n  }\n}\n\nfunction buildBaseInfo(): BaseInfo {\n  return { channel_version: CHANNEL_VERSION }\n}\n\nasync function parseJsonResponse(response: Response, label: string): Promise<unknown> {\n  const text = await response.text()\n  let raw: unknown\n  try {\n    raw = text ? JSON.parse(text) : {}\n  } catch {\n    throw new ApiError(`${label} returned non-JSON (HTTP ${response.status})`, {\n      status: response.status,\n      payload: text.slice(0, 200)\n    })\n  }\n\n  if (!response.ok) {\n    const body = ApiErrorBodySchema.safeParse(raw)\n    const parsed = body.success ? body.data : {}\n    throw new ApiError(parsed.errmsg ?? `${label} failed with HTTP ${response.status}`, {\n      status: response.status,\n      code: parsed.errcode,\n      payload: raw\n    })\n  }\n\n  const body = ApiErrorBodySchema.safeParse(raw)\n  if (body.success && typeof body.data.ret === 'number' && body.data.ret !== 0) {\n    throw new ApiError(body.data.errmsg ?? `${label} failed`, {","sourceCodeStart":363,"sourceCodeEnd":399,"githubUrl":"https://github.com/CherryHQ/cherry-studio/blob/726446b54cd69ffe51a276638672f6d95ca0768c/src/main/ai/channels/adapters/wechat/WeChatProtocol.ts#L363-L399","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before — single fetch, throws on any non-JSON gateway blip\nconst raw = await apiFetch(baseUrl, '/ilink/bot/getupdates', body, token, uin, 40_000, signal)\n\n// after — one bounded retry for transient gateway HTML\nasync function apiFetchWithRetry(...): Promise<unknown> {\n  try {\n    return await apiFetch(...)\n  } catch (e) {\n    if (e instanceof ApiError && e.status >= 500) {\n      await delay(1_000)\n      return await apiFetch(...)\n    }\n    throw e\n  }\n}","handlingStrategy":"retry","validationCode":"// You cannot prevent a gateway from returning HTML, but you can bound the retry.\n// Wrap the WeChat API call in a single-retry helper for transient 5xx/non-JSON:\nasync function fetchWeChatJsonWithRetry<T>(\n  fn: () => Promise<T>,\n  retries = 1\n): Promise<T> {\n  try {\n    return await fn()\n  } catch (e) {\n    if (e instanceof ApiError && e.status >= 500 && retries > 0) {\n      await delay(1_000)\n      return await fn()\n    }\n    throw e\n  }\n}","typeGuard":"// Distinguish a non-JSON ApiError from other ApiError variants\nfunction isNonJsonApiError(e: unknown): boolean {\n  return (\n    e instanceof ApiError &&\n    /returned non-JSON/.test(e.message)\n  )\n}\n\nfunction isTransientGatewayError(e: unknown): boolean {\n  return isNonJsonApiError(e) && (e as ApiError).status >= 500\n}","tryCatchPattern":"try {\n  const updates = await getUpdates(baseUrl, token, uin, cursor, signal)\n} catch (e) {\n  if (isTransientGatewayError(e)) {\n    // runLoop already backs off with retryDelayMs; let it handle this\n    logger.warn('WeChat gateway returned non-JSON, will retry', { status: (e as ApiError).status })\n    return // runLoop's catch block increments retryDelayMs\n  }\n  if (isNonJsonApiError(e)) {\n    // Persistent non-JSON from a 2xx/4xx — likely a protocol change or proxy\n    logger.error('WeChat endpoint returned non-JSON unexpectedly', { payload: (e as ApiError).payload })\n  }\n  throw e\n}","preventionTips":["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."],"tags":["wechat","network","api-error","protocol","gateway"],"backgroundTag":null,"analyzedSha":"726446b54cd69ffe51a276638672f6d95ca0768c","analyzedAt":"2026-08-12T17:30:37.448Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}