{"record":{"id":"67c2f43001201cda","repo":"actualbudget/actual","slug":"getservererrorreason-json","errorCode":null,"errorMessage":"getServerErrorReason(json)","messagePattern":"getServerErrorReason\\(json\\)","errorType":"http","errorClass":"PostError","httpStatus":null,"severity":"error","filePath":"packages/loot-core/src/server/post.ts","lineNumber":23,"sourceCode":"\nimport { PostError } from './errors';\n\nexport function getServerErrorReason(error) {\n  return error.reason === 'unauthorized' && error.details === 'token-not-found'\n    ? 'token-expired'\n    : error.reason;\n}\n\nfunction throwIfNot200(res: Response, text: string) {\n  if (res.status !== 200) {\n    if (res.status === 500) {\n      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","sourceCodeStart":5,"sourceCodeEnd":41,"githubUrl":"https://github.com/actualbudget/actual/blob/d4334cb6e6123f4d3bcea1ad6166608884c7e658/packages/loot-core/src/server/post.ts#L5-L41","documentation":"PostError carrying the server-provided error 'reason' extracted from a JSON error body (getServerErrorReason) when a sync response is non-200 with Content-Type application/json. The message shown is a placeholder for whatever reason string the server sent (e.g. 'file-has-changed', 'unauthorized').","triggerScenarios":"Any post/del/patch/postBinary call receiving a non-200 (non-500) JSON response, e.g. 401 invalid token, 409 file-has-changed during sync, 400 invalid request.","commonSituations":"Expired or wrong encryption/sync password, another device modified the file causing file-has-changed, self-hosted server rejecting an old client, bad budget id in hand-rolled API calls.","solutions":["Read the PostError's message/reason field to see the server's actual reason and act on it","For 'unauthorized', re-set the sync credentials (server URL, password)","For 'file-has-changed', re-download the budget or force re-sync","Ensure client and server versions are compatible"],"exampleFix":"// before\ntry { await post(url, data); } catch (e) { console.error(e); }\n// after\ntry { await post(url, data); }\ncatch (e) {\n  if (e instanceof PostError && e.message === 'file-has-changed') {\n    await forceDownload();\n  } else { throw e; }\n}","handlingStrategy":"try-catch","validationCode":null,"typeGuard":"function isPostErrorWithReason(e: unknown, reason: string): e is PostError {\n  return e instanceof PostError && e.message === reason;\n}","tryCatchPattern":"try {\n  await post(url, data);\n} catch (e) {\n  if (e instanceof PostError) {\n    switch (e.message) {\n      case 'unauthorized': return reauth();\n      case 'file-has-changed': return handleConflict();\n      default: throw e;\n    }\n  }\n  throw e;\n}","preventionTips":["Always handle known server reasons explicitly (unauthorized, file-has-changed, etc.)","Keep sync credentials/passwords up to date","Match client and server versions to avoid rejection","Log the PostError reason for diagnostics before swallowing it"],"tags":["network","http","sync-server"],"backgroundTag":"server-error-response","analyzedSha":"d4334cb6e6123f4d3bcea1ad6166608884c7e658","analyzedAt":"2026-08-29T01:02:11.213Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}