{"record":{"id":"4c2923e9e594ef1a","repo":"actualbudget/actual","slug":"text-raw-server-response-body","errorCode":null,"errorMessage":"text (raw server response body)","messagePattern":"text \\(raw server response body\\)","errorType":"http","errorClass":"PostError","httpStatus":null,"severity":"error","filePath":"packages/loot-core/src/server/post.ts","lineNumber":38,"sourceCode":"    const contentType = res.headers.get('Content-Type') ?? '';\n    if (contentType.toLowerCase().indexOf('application/json') !== -1) {\n      const json = JSON.parse(text);\n      throw new PostError(getServerErrorReason(json));\n    }\n\n    // Actual Sync Server may be exposed via a tunnel (e.g. ngrok). Tunnel errors should be treated as network errors.\n    const tunnelErrorHeaders = ['ngrok-error-code'];\n    const tunnelError = tunnelErrorHeaders.some(header =>\n      res.headers.has(header),\n    );\n\n    if (tunnelError) {\n      // Tunnel errors are present when the tunnel is active and the server is not reachable e.g. server is offline\n      // When we experience a tunnel error we treat it as a network failure\n      throw new PostError('network-failure');\n    }\n\n    throw new PostError(text);\n  }\n}\n\nexport async function post(\n  url: RequestInfo,\n  data: unknown,\n  headers = {},\n  timeout: number | null = null,\n  // Optional caller-provided abort signal. Used by Enable Banking poll\n  // cancellation so the user can interrupt the 5-minute long-poll.\n  externalSignal?: AbortSignal | null,\n) {\n  let text: string;\n  let res: Response;\n\n  const controller = new AbortController();\n  const timeoutId =\n    timeout != null ? setTimeout(() => controller.abort(), timeout) : undefined;","sourceCodeStart":20,"sourceCodeEnd":56,"githubUrl":"https://github.com/actualbudget/actual/blob/d4334cb6e6123f4d3bcea1ad6166608884c7e658/packages/loot-core/src/server/post.ts#L20-L56","documentation":"PostError whose message is the raw, non-JSON response body, thrown for any non-200 response that is not a 500, not JSON, and has no tunnel error header. The body text (HTML error page, plain-text error, proxy message) becomes the error message verbatim.","triggerScenarios":"Non-200 response with a non-JSON Content-Type, e.g. 404 HTML page from a wrong URL, 401 from an authenticating reverse proxy, 502/503 plain-text responses from nginx.","commonSituations":"Sync URL typo pointing at a web root that returns an HTML 404, reverse proxy (nginx/Cloudflare) intercepting with an error or login page, server behind maintenance mode, wrong path after server version upgrade.","solutions":["Inspect the error message text to identify what actually returned it (HTML page, proxy banner)","Verify the sync server URL includes the correct host and path","Bypass the proxy and test the server URL directly to see if the proxy is the responder","Check proxy/auth configuration (Cloudflare Access, basic auth) for the sync endpoint"],"exampleFix":null,"handlingStrategy":"fallback","validationCode":"// probe the endpoint before real calls\nconst probe = await fetch(syncUrl);\nconst isJson = (probe.headers.get('Content-Type') ?? '').includes('application/json');\nif (!isJson) throw new Error('Sync URL is not returning JSON — check URL/proxy');","typeGuard":null,"tryCatchPattern":"try {\n  await post(url, data);\n} catch (e) {\n  if (e instanceof PostError && /<html|<!doctype/i.test(e.message)) {\n    showSyncUrlDiagnostics(); // HTML body => wrong URL or proxy page\n    return;\n  }\n  throw e;\n}","preventionTips":["Validate the sync server URL points at the API root, not a web page","Exclude the sync endpoint from proxy auth/CDN challenge pages","Pin the server path when upgrading (API path changes)","Test with curl to confirm the endpoint returns JSON before configuring clients"],"tags":["network","http","proxy"],"backgroundTag":"unexpected-http-response-body","analyzedSha":"d4334cb6e6123f4d3bcea1ad6166608884c7e658","analyzedAt":"2026-08-29T01:02:11.213Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}