Skip to content

Errors

Every failure in the client is one type: ColibriError. It carries a code, a domain, an HTTP status where there was one, whether retrying could help, and the Sentry event ID once it has been reported. User-facing wording never comes from the error itself, it comes from a catalog keyed by the code, so the same failure reads the same way everywhere and a raw TypeError: Failed to fetch can never reach a user.

  • Directorypackages/client/src/errors/
    • codes.ts every code, its domain, and whether it is retryable
    • error.ts the client’s ColibriError class
    • classify.ts wire and thrown values → ColibriError
    • copy.ts code → what the user reads
    • report.ts reportError, and the account opt-in
    • show-error.ts showError, the toast surface
    • oauth.ts what a data server reports while signing in
    • native.ts Tauri invoke failures

An AppView error is declared in the errors array of its lexicon. Read error off the response and match it against that array for the method you called. Names are specific, such as CommunityNotFound, ChannelNotFound, RoleHierarchy, CredentialsUnavailable and Banned. codes.ts lists every code the client handles, its domain, and whether it is retryable.

An AppView error arrives as {error, message} with an HTTP status. call() in packages/client/src/atproto/xrpc/request.ts turns that into a ColibriError and hands callers an XrpcResult<T>:

const res = await user.xrpc.call(colibri.community.getCommunity.main, {
params: { community },
});
if (!res.ok) return showError(res.error, { retry: refetch });
use(res.data);

Nothing on the request path decides what the user sees. showError looks up the copy, and ErrorState does the same for a panel that would otherwise render blank.

Which steps apply depends on where the failure comes from.

A lexicon’s errors array is the only declaration of what a method can return, so it’s the first and, on the AppView side, the only thing you edit.

  1. Declare it in the lexicon. In the AppView repository, under packages/lexicons/lexicons/social/colibri/**, add an entry to the method’s errors array: a PascalCase name and a description. packages/lexicons/src/conformance.test.ts fails the build if the name isn’t PascalCase or the description is missing, and separately requires every method to declare at least one error.

  2. Emit it from the handler, throwing the XRPCError subclass from @atproto/xrpc-server that matches the HTTP status you want (ForbiddenError, InvalidRequestError, and so on), with the lexicon’s error name as the second argument. apps/appview/src/errors.ts and apps/appview/src/routes/failures.ts collect the conversions already in use, from a domain error (CommunityCredentialError, MembershipError, ModerationError, and others) to the right XRPCError. Reuse one of those rather than constructing an XRPC error inline where a matching domain error already exists.

  3. Write the copy in the client’s copy.ts. A test fails if any code lacks it.

Nothing checks that a code a handler throws is declared in the lexicon it belongs to, or the reverse, so keep the lexicon and the handler in the same pull request.

Generic 500s are not declared, the same way atproto does not declare them. They arrive as InternalError, which lives in the client’s codes.ts like every other code.

Failures that never touch the AppView (transport, session, media devices, voice, native, local storage) are hand-written. Add the code to the matching union in codes.ts, give it a domain in DOMAIN_BY_CODE, add it to RETRYABLE_CODES if retrying could plausibly work, and write its copy. If something should turn a raw thrown value into it, teach classifyThrown about that.

Write the title as what happened from the user’s side, not what the code did. Say what they can do about it in the description, or leave it out.

Forbidden: {
title: "You don't have permission to do that.",
description: "Ask a moderator if you think you should.",
},

Avoid a title that only restates the code ("Forbidden"), and avoid promising something you do not do. Server messages are kept on serverMessage for diagnostics but are never rendered on their own, because they are written for developers rather than for the person reading them.

Failure Surface
An action the user just took showError(err, { retry }), a toast
A panel that would otherwise be blank <ErrorState error={err} retry={...} />
A form field the server rejected <TextFieldErrorMessage errors={err.fields} />
A subtree that threw while rendering wrap it in <SectionBoundary name="...">
Offline, reconnecting already handled by AppReconnectingIndicator
Unrecoverable, whole app the root boundary in app.tsx

showError reports before it renders, so the toast can carry the Sentry event ID. Pass report: false when something else already reported the same failure, so it is not counted twice.

Swallowing a failure is sometimes right. Make it obvious that it was a decision, and never let a read failure look like absence:

try {
record = await getRecord(...);
} catch (err) {
if (!isRecordNotFound(err)) throw classifyThrown(err);
}

createLogger(scope) gives you debug, info, warn and error. Everything goes into an in-memory ring buffer that is attached to Sentry reports and included in the diagnostics users copy from the About page, and warn and error also become Sentry breadcrumbs. Messages and structured data are redacted for tokens, JWTs and emails before being stored.

const log = createLogger("voice");
log.warn("microphone unavailable, joining listen-only", {
code: classifyThrown(err).code,
});

Put detail in the second argument rather than interpolating it into the message, so entries stay groupable. console.* is rejected by Biome in packages/client, and only the logger and the two dev-only diagnostic sinks may use it.

Log the classified code, never the raw error. The ring buffer is serialised with JSON.stringify, and an Error has no enumerable own properties, so { error: err } is stored as {"error":{}} and the diagnostics a user copies from the About page tell you nothing. A code is stable enough to group on and safe to keep. When a failure carries useful context, add it as its own field next to the code:

log.warn("resolving the PDS host failed", { code: classifyThrown(err).code, did });

If you need the full error preserved rather than summarised, that is what reportError and showError are for. They send the cause to Sentry.

pnpm --filter @colibri-social/client sandbox and pick Errors from the gallery. It lists every code with its copy, retryability and domain, fires each surface, contains a real render crash, and emits one log line per level. It runs without a Sentry DSN, so nothing leaves your machine.

At runtime, window.__colibriLog exposes dump(), entries(), setLevel() and setVerbose().