mirror of
https://github.com/actualbudget/actual.git
synced 2026-07-26 22:45:58 -05:00
[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:
co-authored by
Claude
parent
566ee38290
commit
a862d463d7
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user