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
ColibriErrorclass - 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
invokefailures
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.
The path a failure takes
Section titled “The path a failure takes”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.
Adding an error
Section titled “Adding an error”Which steps apply depends on where the failure comes from.
A new AppView error
Section titled “A new AppView error”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.
-
Declare it in the lexicon. In the AppView repository, under
packages/lexicons/lexicons/social/colibri/**, add an entry to the method’serrorsarray: a PascalCasenameand adescription.packages/lexicons/src/conformance.test.tsfails the build if the name isn’t PascalCase or the description is missing, and separately requires every method to declare at least one error. -
Emit it from the handler, throwing the
XRPCErrorsubclass from@atproto/xrpc-serverthat 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.tsandapps/appview/src/routes/failures.tscollect the conversions already in use, from a domain error (CommunityCredentialError,MembershipError,ModerationError, and others) to the rightXRPCError. Reuse one of those rather than constructing an XRPC error inline where a matching domain error already exists. -
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.
A client-side error
Section titled “A client-side error”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.
Choosing what the user reads
Section titled “Choosing what the user reads”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.
Picking a surface
Section titled “Picking a surface”| 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.
Ignoring a failure
Section titled “Ignoring a failure”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);}Logging
Section titled “Logging”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.
Seeing all of it
Section titled “Seeing all of it”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().