actualbudget/actual · critical

internal-error

internal-error

Error message

internal-error

What it means

The /sync endpoint parses the request body as a binary protobuf (SyncRequest). If fromBinary fails — the body is not valid SyncRequest protobuf — the server logs the error and returns 500 with reason 'internal-error'. Note this is a client payload problem surfaced as a server error.

Source

Thrown at packages/sync-server/src/app-sync.ts:144

}

function requireFileAccess(file: File, userId: string) {
  if (requireFileOwner(file, userId) === null) {
    return null;
  }
  if (UserService.countUserAccess(file.id, userId) > 0) {
    return null;
  }
  return 'file-access-not-allowed';
}

app.post('/sync', async (req, res): Promise<void> => {
  let requestPb;
  try {
    requestPb = fromBinary(SyncRequestSchema, req.body);
  } catch (e) {
    console.log('Error parsing sync request', e);
    res.status(500);
    res.send({ status: 'error', reason: 'internal-error' });
    return;
  }

  const fileId = requestPb.fileId || null;
  const groupId = requestPb.groupId || null;
  const keyId = requestPb.keyId || null;
  const since = requestPb.since || null;
  const messages = requestPb.messages;

  if (!since) {
    res.status(422).send({
      details: 'since-required',
      reason: 'unprocessable-entity',
      status: 'error',
    });
    return;
  }

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Serialize the request with toBinary(SyncRequestSchema, request) using the same protobuf schema version as the server and send it as the raw body with an appropriate binary content type.
  2. Upgrade/align the client (@actual-app/api or desktop-client) with the sync-server version.
  3. Check intermediate proxies are not altering the body, and verify the server mounts the raw-body parser for /sync.

Example fix

// before: sending JSON to /sync
await fetch(serverUrl + '/sync', { method: 'POST', body: JSON.stringify(request) }); // 500 internal-error
// after: send the protobuf binary
import { toBinary, SyncRequestSchema } from './proto/sync';
const body = toBinary(SyncRequestSchema, request);
await fetch(serverUrl + '/sync', { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body });
Defensive patterns

Strategy: try-catch

Validate before calling

import { toBinary, SyncRequestSchema } from './sync.proto';
const body = toBinary(SyncRequestSchema, request); // fail fast locally if the shape is wrong
if (!(body instanceof Uint8Array) || body.length === 0) throw new Error('sync body must be a non-empty protobuf');

Type guard

const isSyncRequest = (r) => typeof r.fileId === 'string' && Array.isArray(r.messages);

Try / catch

try {
  const res = await fetch(serverUrl + '/sync', { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body });
  if (res.status === 500) {
    const j = await res.json();
    if (j.reason === 'internal-error') throw new Error('server could not parse the sync request: check protobuf schema/version');
  }
  return res;
} catch (e) { throw e; }

Prevention

When it happens

Trigger: POSTing to /sync with a non-protobuf body (JSON, form data), a truncated/corrupted protobuf, a SyncRequest serialized against an incompatible schema version, or a missing Content-Type so express does not deliver the raw buffer to fromBinary.

Common situations: Custom scripts hand-rolling sync calls; client and server versions mismatched after an upgrade; proxies/CDNs mangling the binary body; forgetting the correct express raw-body parser configuration in front of the app.

Related errors


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