[AI] api: attach stable error codes that survive the worker boundary (#8437)

* [AI] Attach stable error codes that survive the worker boundary

Rejections from the backend worker reached consumers as an English
message only: the machine-readable reason was stripped when the error
was serialized for postMessage, so API consumers had to regex the prose
to categorize failures.

- Serialize rejected handler errors through a shared helper that lifts
  the reason slug onto a `code` field (plus `name`) on the envelope, so
  it survives the worker/utility-process boundary. `message` is
  unchanged, so the change is purely additive.
- Tag the common connect failures with codes at their throw sites:
  init auth failures (network-failure, invalid-password, token-expired)
  and api/download-budget, api/load-budget, api/sync, api/bank-sync
  (budget-not-found, missing-key, decrypt-failure, ...).
- Harden the error post: if the original error holds a non-cloneable
  value (e.g. an Emscripten ErrnoError carrying methods), postMessage
  used to throw a DataCloneError in the worker and that clone failure
  replaced the real error. Retry with a plain cloneable shape instead.
- Add an integration test driving the real web client/server connection
  pair across a structured-clone channel, document the codes in the API
  reference, and add a release note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AG6f77cRxhwPFjsNpQRmYo

* [AI] Remove worker-boundary connection integration test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AG6f77cRxhwPFjsNpQRmYo

* [AI] Remove prose comment from connection error helpers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AG6f77cRxhwPFjsNpQRmYo

* [AI] Simplify worker-boundary error posting

- Hoist the three-way reply-envelope dispatch out of the two platform
  connection files into a single postErrorReply helper, so the envelope
  rules and the DataCloneError fallback live in one place.
- Return APIError envelopes from coerceError unchanged, and drop the
  unreachable fallbacks and unused meta field from the cloneable shape.
- Let withErrorCode accept an undefined code, removing the one-off
  ternary at the bank-sync throw site.
- Declare the client-side connection error shape once as
  ConnectionError instead of twice inline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AG6f77cRxhwPFjsNpQRmYo

* [AI] Address review feedback on error-code contract wording and types

- Docs and release note: scope the stable-code promise to the common
  documented failures, and say errors "usually" carry a message.
- ConnectionError: model APIError (message required) and ServerError
  (message may be absent) as separate variants to match the wire shape,
  and adapt the sendCatch consumers in SavedFilterMenuButton.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AG6f77cRxhwPFjsNpQRmYo

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Matiss Janis Aboltins
2026-07-16 06:08:18 +00:00
committed by GitHub
co-authored by Claude
parent 566ee38290
commit a862d463d7
10 changed files with 246 additions and 86 deletions
@@ -80,7 +80,7 @@ export function SavedFilterMenuButton({
});
if (response.error) {
setErr(response.error.message);
setErr(response.error.message ?? null);
setNameOpen(true);
return;
}
@@ -126,7 +126,7 @@ export function SavedFilterMenuButton({
});
if (response.error) {
setErr(response.error.message);
setErr(response.error.message ?? null);
setNameOpen(true);
return;
}
@@ -152,7 +152,7 @@ export function SavedFilterMenuButton({
});
if (response.error) {
setErr(response.error.message);
setErr(response.error.message ?? null);
setNameOpen(true);
return;
}
+50
View File
@@ -114,6 +114,56 @@ The browser build is fully self-contained: the Web Worker and its WebAssembly an
The engine uses `SharedArrayBuffer`, so the page that runs the API must be served **cross-origin isolated**: over HTTPS, with `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`. This is a hosting/server requirement (it cannot be bundled away). See [Enabling SharedArrayBuffer Access](../troubleshooting/shared-array-buffer.md). In local development, set the same headers on your dev server (for example via a small Vite middleware plugin).
:::
## Handling Errors
When an API method fails, the rejected error usually carries a human-readable `message` in English. For the most common connection and download failures, the error also carries a stable, machine-readable `code`. Use `code` when your app needs to react to a specific kind of failure (for example, to show its own translated message) — matching on the text of `message` is fragile because the wording can change between releases.
```js
try {
await api.init({
dataDir: '/some/path',
serverURL: 'http://localhost:5006',
password: 'hunter2',
});
await api.downloadBudget('1cfdbb80-6274-49bf-b0c2-737235a4c81f');
} catch (error) {
switch (error.code) {
case 'network-failure':
case 'network':
// The server could not be reached. Check the serverURL.
break;
case 'invalid-password':
// The server password is wrong.
break;
case 'budget-not-found':
// No budget file matches the given sync ID.
break;
default:
// Fall back to the message text.
console.error(error.message);
}
}
```
These are the codes for the common failures:
| Code | Meaning |
| ------------------------ | ---------------------------------------------------------------------------------------------------- |
| `network-failure` | The server could not be reached — the `serverURL` is wrong, or the server is offline or unreachable. |
| `network` | Same as `network-failure`, reported by the download and encryption-key checks. |
| `invalid-password` | The server password given to `init` is wrong. |
| `token-expired` | The session token given to `init` is invalid or has expired. |
| `unauthorized` | The client is not logged in to the server. |
| `budget-not-found` | No budget file matches the given sync ID. |
| `missing-key` | The budget file is end-to-end encrypted, and no encryption password was given. |
| `decrypt-failure` | The budget file could not be decrypted — the encryption password is wrong. |
| `old-key-style` | The budget file uses an old, unsupported encryption key style. |
| `out-of-sync-migrations` | The budget file needs a newer version of Actual — update the API package. |
:::note
`code` is present for the common connection and download failures listed above. Other errors may only carry a `message`, so always keep a fallback.
:::
## Writing Data Importers
If you are using another app, like YNAB or Mint, you might want to migrate your data into Actual. Right now, Actual officially supports [importing YNAB4 data](../migration/ynab4.md) and [importing nYNAB data](../migration/nynab.md) (and it works very well). But if you want to import all of your data into Actual, you can write a custom importer.
@@ -8,6 +8,18 @@ import type { ServerEvents } from '#types/server-events';
export declare function init(socket?: Worker | MessagePort): Promise<unknown>;
export type Init = typeof init;
/** Error shape posted by the backend when a handler rejects. */
export type ConnectionError = {
/** Stable, machine-readable failure code (e.g. 'network-failure') */
code?: string;
name?: string;
cause?: unknown;
} & (
| { type: 'APIError'; message: string }
// A ServerError can be built from a rejection without a message
| { type: 'ServerError'; message?: string }
);
/**
* Send a command to the browser server.
*
@@ -25,14 +37,7 @@ export declare function send<K extends keyof Handlers>(
options: { catchErrors: true },
): Promise<
| { data: Awaited<ReturnType<Handlers[K]>>; error: undefined }
| {
data: undefined;
error: {
type: 'APIError' | 'ServerError';
message: string;
cause?: unknown;
};
}
| { data: undefined; error: ConnectionError }
>;
export declare function send<K extends keyof Handlers>(
name: K,
@@ -54,14 +59,7 @@ export declare function sendCatch<K extends keyof Handlers>(
args?: Parameters<Handlers[K]>[0],
): Promise<
| { data: Awaited<ReturnType<Handlers[K]>>; error: undefined }
| {
data: undefined;
error: {
type: 'APIError' | 'ServerError';
message: string;
cause?: unknown;
};
}
| { data: undefined; error: ConnectionError }
>;
export type SendCatch = typeof sendCatch;
@@ -0,0 +1,99 @@
export type TransferableError = {
type: string;
message?: string;
// Stable, machine-readable failure code (e.g. 'network-failure',
// 'invalid-password', 'budget-not-found'). Sourced from the error's
// `reason`/`code` and guaranteed to survive the boundary.
code?: string;
name?: string;
stack?: string;
cause?: unknown;
};
function getField(error: unknown, field: string): unknown {
return typeof error === 'object' && error !== null
? (error as Record<string, unknown>)[field]
: undefined;
}
function getStringField(error: unknown, field: string): string | undefined {
const value = getField(error, field);
return typeof value === 'string' ? value : undefined;
}
// Internal errors carry their machine-readable slug on `reason` (PostError,
// SyncError, …) or `code` (BankSyncError, Node system errors); `errno` covers
// Emscripten filesystem errors.
function getErrorCode(error: unknown): string | undefined {
const code =
getField(error, 'reason') ??
getField(error, 'code') ??
getField(error, 'errno');
if (typeof code === 'string') {
return code;
}
if (typeof code === 'number') {
return String(code);
}
return undefined;
}
function coerceError(error: unknown): TransferableError {
if (getField(error, 'type') === 'APIError') {
return error as TransferableError;
}
return {
type: 'ServerError',
message: getStringField(error, 'message'),
code: getErrorCode(error),
name: getStringField(error, 'name'),
cause: error,
};
}
// A reduced shape that is always structured-cloneable: `cause` (the original
// error) is dropped, everything kept is a plain string.
function toCloneableError(error: TransferableError): TransferableError {
return {
type: error.type,
message: error.message,
code: error.code,
name: error.name,
stack: getStringField(error.cause, 'stack'),
};
}
// Post a rejected handler result in the envelope the request expects, and
// return the serialized error for the caller's reporting. If the structured
// clone inside `post` fails — the error held a non-cloneable value, e.g. an
// Emscripten ErrnoError carrying methods — retry with a plain, always
// cloneable shape so the real failure isn't replaced by a DataCloneError.
export function postErrorReply(
post: (message: unknown) => void,
request: { id: unknown; name: string; catchErrors?: boolean },
rejection: unknown,
): TransferableError {
const { id, name, catchErrors } = request;
const error = coerceError(rejection);
function buildMessage(err: TransferableError) {
if (name.startsWith('api/')) {
// The API is newer and does automatically forward errors
return { type: 'reply', id, error: err };
}
if (catchErrors) {
return { type: 'reply', id, result: { error: err, data: null } };
}
return { type: 'error', id, error: err };
}
try {
post(buildMessage(error));
} catch {
post(buildMessage(toCloneableError(error)));
}
return error;
}
@@ -4,16 +4,9 @@ import { logger } from '#platform/server/log';
import { APIError } from '#server/errors';
import { isMutating, runHandler } from '#server/mutators';
import { postErrorReply } from './errors';
import type * as T from './index-types';
function coerceError(error) {
if (error.type && error.type === 'APIError') {
return error;
}
return { type: 'ServerError', message: error.message, cause: error };
}
export const init: T.Init = function (_socketName, handlers) {
process.parentPort.on('message', ({ data }) => {
const { id, name, args, undoTag, catchErrors } = data;
@@ -35,25 +28,11 @@ export const init: T.Init = function (_socketName, handlers) {
});
},
nativeError => {
const error = coerceError(nativeError);
if (name.startsWith('api/')) {
// The API is newer and does automatically forward
// errors
process.parentPort.postMessage({
type: 'reply',
id,
error,
});
} else if (catchErrors) {
process.parentPort.postMessage({
type: 'reply',
id,
result: { error, data: null },
});
} else {
process.parentPort.postMessage({ type: 'error', id, error });
}
const error = postErrorReply(
message => process.parentPort.postMessage(message),
{ id, name, catchErrors },
nativeError,
);
if (error.type === 'ServerError' && name !== 'api/load-budget') {
captureException(nativeError);
@@ -4,6 +4,7 @@ import { logger } from '#platform/server/log';
import { APIError } from '#server/errors';
import { isMutating, runHandler } from '#server/mutators';
import { postErrorReply } from './errors';
import type * as T from './index-types';
function getGlobalObject() {
@@ -23,14 +24,6 @@ function getGlobalObject() {
getGlobalObject().__globalServerChannel = null;
function coerceError(error) {
if (error.type && error.type === 'APIError') {
return error;
}
return { type: 'ServerError', message: error.message, cause: error };
}
export const init: T.Init = function (serverChn, handlers) {
const serverChannel = serverChn as Window;
getGlobalObject().__globalServerChannel = serverChannel;
@@ -66,21 +59,11 @@ export const init: T.Init = function (serverChn, handlers) {
});
},
nativeError => {
const error = coerceError(nativeError);
if (name.startsWith('api/')) {
// The API is newer and does automatically forward
// errors
serverChannel.postMessage({ type: 'reply', id, error });
} else if (catchErrors) {
serverChannel.postMessage({
type: 'reply',
id,
result: { error, data: null },
});
} else {
serverChannel.postMessage({ type: 'error', id, error });
}
const error = postErrorReply(
message => serverChannel.postMessage(message),
{ id, name, catchErrors },
nativeError,
);
// Only report internal errors
if (error.type === 'ServerError') {
+34 -13
View File
@@ -42,7 +42,7 @@ import { isTrackingBudget } from './budget/actions';
import * as cloudStorage from './cloud-storage';
import type { RemoteFile } from './cloud-storage';
import * as db from './db';
import { APIError } from './errors';
import { APIError, withErrorCode } from './errors';
import { runMutator } from './mutators';
import * as prefs from './prefs';
import * as sheet from './sheet';
@@ -170,7 +170,7 @@ handlers['api/load-budget'] = async function ({ id }) {
} else {
connection.send('show-budgets');
throw new Error(getSyncError(error, id));
throw withErrorCode(new Error(getSyncError(error, id)), error);
}
}
};
@@ -189,12 +189,18 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
if (!localBudget) {
const files = await handlers['get-remote-files']();
if (!files) {
throw new Error('Could not get remote files');
throw withErrorCode(
new Error('Could not get remote files'),
'network-failure',
);
}
const file = files.find(f => f.groupId === syncId);
if (!file) {
throw new Error(
`Budget "${syncId}" not found. Check the sync id of your budget in the Advanced section of the settings page.`,
throw withErrorCode(
new Error(
`Budget "${syncId}" not found. Check the sync id of your budget in the Advanced section of the settings page.`,
),
'budget-not-found',
);
}
@@ -206,8 +212,11 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
// Set the e2e encryption keys
if (activeFile.encryptKeyId) {
if (!password) {
throw new Error(
`File ${activeFile.name} is encrypted. Please provide a password.`,
throw withErrorCode(
new Error(
`File ${activeFile.name} is encrypted. Please provide a password.`,
),
'missing-key',
);
}
@@ -216,7 +225,10 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
password,
});
if (result.error) {
throw new Error(getTestKeyError(result.error));
throw withErrorCode(
new Error(getTestKeyError(result.error)),
result.error.reason,
);
}
}
@@ -225,8 +237,11 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
await handlers['load-budget']({ id: localBudget.id });
const result = await handlers['sync-budget']();
if (result.error) {
throw new Error(
getSyncError(result.error.reason, localBudget.id, result.error.meta),
throw withErrorCode(
new Error(
getSyncError(result.error.reason, localBudget.id, result.error.meta),
),
result.error.reason,
);
}
return;
@@ -238,7 +253,10 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
});
if (result.error) {
logger.log('Full error details', result.error);
throw new Error(getDownloadError(result.error));
throw withErrorCode(
new Error(getDownloadError(result.error)),
result.error.reason,
);
}
await handlers['load-budget']({ id: result.id });
};
@@ -256,7 +274,10 @@ handlers['api/sync'] = async function () {
const { id } = prefs.getPrefs();
const result = await handlers['sync-budget']();
if (result.error) {
throw new Error(getSyncError(result.error.reason, id, result.error.meta));
throw withErrorCode(
new Error(getSyncError(result.error.reason, id, result.error.meta)),
result.error.reason,
);
}
};
@@ -295,7 +316,7 @@ handlers['api/bank-sync'] = async function (args) {
const errors = allErrors.filter(e => e != null);
if (errors.length > 0) {
throw new Error(getBankSyncError(errors[0]));
throw withErrorCode(new Error(getBankSyncError(errors[0])), errors[0].code);
}
};
+14
View File
@@ -90,6 +90,20 @@ export function APIError(
return { type: 'APIError', message: msg, meta };
}
/**
* Tag an error with a stable, machine-readable failure code (e.g.
* 'network-failure', 'invalid-password', 'budget-not-found'). The code is
* kept when the error crosses the worker boundary (see
* `#platform/server/connection`), so API consumers can branch on `err.code`
* instead of parsing the English message.
*/
export function withErrorCode<E extends Error>(
error: E,
code: string | undefined,
): E & { code?: string } {
return code == null ? error : Object.assign(error, { code });
}
export function FileDownloadError(
reason: string,
meta?: {
+14 -4
View File
@@ -19,6 +19,7 @@ import { app as dashboardApp } from './dashboard/app';
import * as db from './db';
import * as encryption from './encryption';
import { app as encryptionApp } from './encryption/app';
import { withErrorCode } from './errors';
import { app as filtersApp } from './filters/app';
import { app as forecastApp } from './forecast/app';
import { app as formulasApp } from './formulas/app';
@@ -291,21 +292,30 @@ export async function init(config: InitConfig) {
if (!user || user.tokenExpired === true) {
// Clear invalid token
await runHandler(handlers['subscribe-set-token'], { token: '' });
throw new Error(
'Authentication failed: invalid or expired session token',
throw withErrorCode(
new Error('Authentication failed: invalid or expired session token'),
'token-expired',
);
}
if (user.offline === true) {
// Clear token since we can't validate
await runHandler(handlers['subscribe-set-token'], { token: '' });
throw new Error('Authentication failed: server offline or unreachable');
throw withErrorCode(
new Error('Authentication failed: server offline or unreachable'),
'network-failure',
);
}
} else if ('password' in config && config.password) {
const result = await runHandler(handlers['subscribe-sign-in'], {
password: config.password,
});
if (result?.error) {
throw new Error(`Authentication failed: ${result.error}`);
// `result.error` is already a machine-readable slug (e.g.
// 'invalid-password', 'network-failure')
throw withErrorCode(
new Error(`Authentication failed: ${result.error}`),
result.error,
);
}
}
} else {
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
Common API failures now include a stable code so apps built on the API can show their own clear message for each kind of failure.