actualbudget/actual · error · PostError

text (raw server response body)

Error message

text (raw server response body)

What it means

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.

Source

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

    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);
  }
}

export async function post(
  url: RequestInfo,
  data: unknown,
  headers = {},
  timeout: number | null = null,
  // Optional caller-provided abort signal. Used by Enable Banking poll
  // cancellation so the user can interrupt the 5-minute long-poll.
  externalSignal?: AbortSignal | null,
) {
  let text: string;
  let res: Response;

  const controller = new AbortController();
  const timeoutId =
    timeout != null ? setTimeout(() => controller.abort(), timeout) : undefined;

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Inspect the error message text to identify what actually returned it (HTML page, proxy banner)
  2. Verify the sync server URL includes the correct host and path
  3. Bypass the proxy and test the server URL directly to see if the proxy is the responder
  4. Check proxy/auth configuration (Cloudflare Access, basic auth) for the sync endpoint
Defensive patterns

Strategy: fallback

Validate before calling

// probe the endpoint before real calls
const probe = await fetch(syncUrl);
const isJson = (probe.headers.get('Content-Type') ?? '').includes('application/json');
if (!isJson) throw new Error('Sync URL is not returning JSON — check URL/proxy');

Try / catch

try {
  await post(url, data);
} catch (e) {
  if (e instanceof PostError && /<html|<!doctype/i.test(e.message)) {
    showSyncUrlDiagnostics(); // HTML body => wrong URL or proxy page
    return;
  }
  throw e;
}

Prevention

When it happens

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

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

Related errors


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