{"record":{"id":"ac5198fe4751eb62","repo":"actualbudget/actual","slug":"network-failure","errorCode":"network-failure","errorMessage":"network-failure","messagePattern":"network-failure","errorType":"error_code","errorClass":"PostError","httpStatus":null,"severity":"critical","filePath":"packages/loot-core/src/server/post.ts","lineNumber":35,"sourceCode":"      throw new PostError(res.status === 500 ? 'internal' : text);\n    }\n\n    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","sourceCodeStart":17,"sourceCodeEnd":53,"githubUrl":"https://github.com/actualbudget/actual/blob/d4334cb6e6123f4d3bcea1ad6166608884c7e658/packages/loot-core/src/server/post.ts#L17-L53","documentation":"PostError thrown when a sync response carries a tunnel error header (e.g. ngrok-error-code), meaning the tunnel is up but the backing sync server is unreachable. It is mapped to the sentinel message 'network-failure' so callers treat it like any connectivity outage.","triggerScenarios":"Accessing a self-hosted sync server through ngrok (or similar) while the local Actual server process is stopped or crashed; the tunnel returns an error page with the tunnel header, non-200.","commonSituations":"ngrok tunnel running but the actual sync-server not started, server container crashed, wrong port forwarded by the tunnel, tunnel pointing at a stale upstream.","solutions":["Start/restart the sync server behind the tunnel and verify it responds locally (curl localhost:5006)","Confirm the tunnel forwards to the correct port","Check tunnel provider dashboard for upstream health errors","Retry sync once the server is reachable"],"exampleFix":null,"handlingStrategy":"retry","validationCode":"// before syncing, check the backing server is reachable\nconst health = await fetch(serverUrl.replace(/\\/$/, '') + '/health');\nif (!health.ok) throw new Error('Sync server behind tunnel is down');","typeGuard":"function isTunnelNetworkFailure(e: unknown): e is PostError {\n  return e instanceof PostError && e.message === 'network-failure';\n}","tryCatchPattern":"try {\n  await post(url, data);\n} catch (e) {\n  if (isTunnelNetworkFailure(e)) {\n    await waitForServer(serverUrl); // poll until tunnel upstream is back\n    return retry(post, [url, data]);\n  }\n  throw e;\n}","preventionTips":["Run the sync server as a supervised service (systemd/docker restart policy) so it survives crashes","Health-check the upstream server independently of the tunnel","Verify tunnel port mapping after every config change","Alert on tunnel error headers in any proxy logs you control"],"tags":["network","tunnel","sync-server"],"backgroundTag":"network-failure","analyzedSha":"d4334cb6e6123f4d3bcea1ad6166608884c7e658","analyzedAt":"2026-08-29T01:02:11.213Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}