actualbudget/actual · error · PostError

getServerErrorReason(json)

Error message

getServerErrorReason(json)

What it means

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').

Source

Thrown at packages/loot-core/src/server/post.ts:23

import { PostError } from './errors';

export function getServerErrorReason(error) {
  return error.reason === 'unauthorized' && error.details === 'token-not-found'
    ? 'token-expired'
    : error.reason;
}

function throwIfNot200(res: Response, text: string) {
  if (res.status !== 200) {
    if (res.status === 500) {
      throw new PostError(res.status === 500 ? 'internal' : text);
    }

    const contentType = res.headers.get('Content-Type') ?? '';
    if (contentType.toLowerCase().indexOf('application/json') !== -1) {
      const json = JSON.parse(text);
      throw new PostError(getServerErrorReason(json));
    }

    // Actual Sync Server may be exposed via a tunnel (e.g. ngrok). Tunnel errors should be treated as network errors.
    const tunnelErrorHeaders = ['ngrok-error-code'];
    const tunnelError = tunnelErrorHeaders.some(header =>
      res.headers.has(header),
    );

    if (tunnelError) {
      // Tunnel errors are present when the tunnel is active and the server is not reachable e.g. server is offline
      // When we experience a tunnel error we treat it as a network failure
      throw new PostError('network-failure');
    }

    throw new PostError(text);
  }
}

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Read the PostError's message/reason field to see the server's actual reason and act on it
  2. For 'unauthorized', re-set the sync credentials (server URL, password)
  3. For 'file-has-changed', re-download the budget or force re-sync
  4. Ensure client and server versions are compatible

Example fix

// before
try { await post(url, data); } catch (e) { console.error(e); }
// after
try { await post(url, data); }
catch (e) {
  if (e instanceof PostError && e.message === 'file-has-changed') {
    await forceDownload();
  } else { throw e; }
}
Defensive patterns

Strategy: try-catch

Type guard

function isPostErrorWithReason(e: unknown, reason: string): e is PostError {
  return e instanceof PostError && e.message === reason;
}

Try / catch

try {
  await post(url, data);
} catch (e) {
  if (e instanceof PostError) {
    switch (e.message) {
      case 'unauthorized': return reauth();
      case 'file-has-changed': return handleConflict();
      default: throw e;
    }
  }
  throw e;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29). Data as JSON: /api/errors/67c2f43001201cda. Report an issue: GitHub.