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
- 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.
- Upgrade/align the client (@actual-app/api or desktop-client) with the sync-server version.
- 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
- Always send protobuf produced by toBinary, never JSON, to /sync
- Keep client and sync-server versions aligned
- Set Content-Type to a binary type and avoid proxies that rewrite bodies
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
- Sync ID is required for sync ${flag}. Set --sync-id or ACTUA
- Could not resolve on-disk budget id for syncId ${syncId} aft
- TrieNode for key ${k} could not be found
- Timestamp.InvalidError: ${data.timestamp}
- Timestamp.ClockDriftError
AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29).
Data as JSON: /api/errors/57b86a39dedb4f13.
Report an issue: GitHub.