From a862d463d7697619ca40a5a7ef227ae48451b60d Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Thu, 16 Jul 2026 07:08:18 +0100 Subject: [PATCH] [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 Claude-Session: https://claude.ai/code/session_01AG6f77cRxhwPFjsNpQRmYo * [AI] Remove worker-boundary connection integration test Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AG6f77cRxhwPFjsNpQRmYo * [AI] Remove prose comment from connection error helpers Co-Authored-By: Claude Fable 5 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 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 Claude-Session: https://claude.ai/code/session_01AG6f77cRxhwPFjsNpQRmYo --------- Co-authored-by: Claude --- .../filters/SavedFilterMenuButton.tsx | 6 +- packages/docs/docs/api/index.md | 50 ++++++++++ .../platform/client/connection/index-types.ts | 30 +++--- .../src/platform/server/connection/errors.ts | 99 +++++++++++++++++++ .../server/connection/index.electron.ts | 33 ++----- .../src/platform/server/connection/index.ts | 29 ++---- packages/loot-core/src/server/api.ts | 47 ++++++--- packages/loot-core/src/server/errors.ts | 14 +++ packages/loot-core/src/server/main.ts | 18 +++- .../stable-api-error-codes.md | 6 ++ 10 files changed, 246 insertions(+), 86 deletions(-) create mode 100644 packages/loot-core/src/platform/server/connection/errors.ts create mode 100644 upcoming-release-notes/stable-api-error-codes.md diff --git a/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx b/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx index fda77c3c82..765d74c8a4 100644 --- a/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx +++ b/packages/desktop-client/src/components/filters/SavedFilterMenuButton.tsx @@ -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; } diff --git a/packages/docs/docs/api/index.md b/packages/docs/docs/api/index.md index cbaa577452..4fa021a956 100644 --- a/packages/docs/docs/api/index.md +++ b/packages/docs/docs/api/index.md @@ -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. diff --git a/packages/loot-core/src/platform/client/connection/index-types.ts b/packages/loot-core/src/platform/client/connection/index-types.ts index 80160f9263..811ee6d441 100644 --- a/packages/loot-core/src/platform/client/connection/index-types.ts +++ b/packages/loot-core/src/platform/client/connection/index-types.ts @@ -8,6 +8,18 @@ import type { ServerEvents } from '#types/server-events'; export declare function init(socket?: Worker | MessagePort): Promise; 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( options: { catchErrors: true }, ): Promise< | { data: Awaited>; error: undefined } - | { - data: undefined; - error: { - type: 'APIError' | 'ServerError'; - message: string; - cause?: unknown; - }; - } + | { data: undefined; error: ConnectionError } >; export declare function send( name: K, @@ -54,14 +59,7 @@ export declare function sendCatch( args?: Parameters[0], ): Promise< | { data: Awaited>; error: undefined } - | { - data: undefined; - error: { - type: 'APIError' | 'ServerError'; - message: string; - cause?: unknown; - }; - } + | { data: undefined; error: ConnectionError } >; export type SendCatch = typeof sendCatch; diff --git a/packages/loot-core/src/platform/server/connection/errors.ts b/packages/loot-core/src/platform/server/connection/errors.ts new file mode 100644 index 0000000000..43816920e6 --- /dev/null +++ b/packages/loot-core/src/platform/server/connection/errors.ts @@ -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)[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; +} diff --git a/packages/loot-core/src/platform/server/connection/index.electron.ts b/packages/loot-core/src/platform/server/connection/index.electron.ts index 04d5b20749..74de4dad09 100644 --- a/packages/loot-core/src/platform/server/connection/index.electron.ts +++ b/packages/loot-core/src/platform/server/connection/index.electron.ts @@ -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); diff --git a/packages/loot-core/src/platform/server/connection/index.ts b/packages/loot-core/src/platform/server/connection/index.ts index 39c57750c8..57f9e6151e 100644 --- a/packages/loot-core/src/platform/server/connection/index.ts +++ b/packages/loot-core/src/platform/server/connection/index.ts @@ -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') { diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index 1b0bc0069d..0701df1c84 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -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); } }; diff --git a/packages/loot-core/src/server/errors.ts b/packages/loot-core/src/server/errors.ts index 0cba4b7f3d..e304f401e5 100644 --- a/packages/loot-core/src/server/errors.ts +++ b/packages/loot-core/src/server/errors.ts @@ -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( + error: E, + code: string | undefined, +): E & { code?: string } { + return code == null ? error : Object.assign(error, { code }); +} + export function FileDownloadError( reason: string, meta?: { diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 58373eb95c..b27be83cac 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -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 { diff --git a/upcoming-release-notes/stable-api-error-codes.md b/upcoming-release-notes/stable-api-error-codes.md new file mode 100644 index 0000000000..26f16afc3c --- /dev/null +++ b/upcoming-release-notes/stable-api-error-codes.md @@ -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.