Error Handling
@palbase/web methods return values and throw on failure — there are no { data, error } envelopes. Two layers of error reach your catch: a BackendError with one of seven kinds, and, for errors your backend declares on a specific endpoint, a generated per-endpoint class that the SDK lifts the BackendError into. This page covers both, the exact mapping from wire response to kind, and the two places that mapping surprises people — server-side validation and rate limits.
Quick example
import { pb, isBackendError } from '@palbase/web';
import { TodosUpdateNotFoundError, TodosUpdateTodoLockedError } from './palbase/client';
async function completeTodo(id: string) {
try {
return await pb.todos.update(id, { completed: true });
} catch (e) {
// 1. Errors your backend declared on this endpoint — typed payloads.
if (e instanceof TodosUpdateNotFoundError) return null;
if (e instanceof TodosUpdateTodoLockedError) {
showToast(`Todo is locked — try again in ${e.data.retryAfter}s`);
return null;
}
// 2. Everything else is a BackendError.
if (isBackendError(e)) {
switch (e.kind) {
case 'unauthorized':
redirectToLogin();
return null;
case 'rateLimited':
// e.retryAfter is set for auth throttling only — see below.
showToast('Slow down.');
return null;
case 'validation':
// Client-side only: a bad argument, a failed upload constraint.
showFieldErrors(e.fields ?? []);
return null;
case 'network':
showToast('You appear to be offline.');
return null;
case 'server':
if (e.code === 'bad_request') return showServerValidation(e);
reportError(e.requestId, e.code);
return null;
default:
reportError(e.requestId, e.code); // decode / notConfigured
return null;
}
}
throw e; // not ours — rethrow
}
}
BackendError
class BackendError extends Error {
readonly name = 'BackendError';
readonly kind: BackendErrorKind; // category — switch on this
readonly code: string; // machine-readable wire code, e.g. 'not_found'
readonly status: number; // HTTP status; 0 when no response was received
readonly requestId?: string; // from the envelope's request_id
readonly fields?: FieldError[]; // only ever set when kind === 'validation'
readonly retryAfter?: number; // seconds; only when kind === 'rateLimited'
readonly data?: unknown; // the envelope's `data` payload
static notConfigured(): BackendError;
}
type FieldError = { field: string; message: string };
Warning: There is no
err.detailson this class, and there are no'forbidden'or'conflict'kinds. A403, a409and a503all arrive as kind'server', discriminated byerr.code. If you have code branching one.kind === 'forbidden'or readinge.details, it has never run.
The seven kinds
type BackendErrorKind =
| 'notConfigured' | 'validation' | 'unauthorized'
| 'rateLimited' | 'server' | 'network' | 'decode';
kind | When | Useful properties |
|---|---|---|
notConfigured | pb was used before the palbase/client barrel was imported | code: 'not_configured' |
validation | A client-side check failed, before the request was sent | code, fields |
unauthorized | HTTP 401, after the automatic refresh-and-retry failed | status, requestId |
rateLimited | HTTP 429, after the SDK's internal retries were exhausted | code, retryAfter, data |
server | Everything else non-2xx — 400, 403, 404, 409, 5xx — plus status-0 non-network errors | code, status, data, requestId |
network | No response at all: offline, DNS failure, a cancelled call | code — 'network_error', or 'aborted' for a cancelled upload |
decode | A 2xx response whose body was not valid JSON | status |
The notConfigured message, verbatim:
Palbe is not configured. Run 'palbase link' in your project and make sure palbe.gen.ts is imported once at app startup.
Mapping rules
The SDK decides the kind in this exact order, and stops at the first match:
code === 'network_error'→network.status === 401→unauthorized.status === 429→rateLimited, withretryAfterread from the body's top-levelretry_after.status === 400and the envelope's nesteddetailskey is a non-empty array of{ field, message }→validation, with that array onfields.- Everything else →
server.
Rule 5 is the fall-through for far more than "a 5xx". It catches a 403, a 404, a 409, a 503, every 400 that does not carry a nested details array, and status-0 errors that are not network failures — such as the auth client's synthetic no_refresh_token, which arrives as kind: 'server' with status: 0.
The wire envelope
Every Palbase service answers a failure with the same envelope. Three variants exist and only the first is universal:
{
"error": "not_found",
"error_description": "Todo with given ID does not exist",
"status": 404,
"request_id": "req_01HX..."
}
error becomes code, error_description becomes message, request_id becomes requestId.
Variant A — a failed @Body or @QueryParams schema. The backend engine adds fields at the top level:
{
"error": "bad_request",
"error_description": "Request body failed validation",
"status": 400,
"request_id": "req_01HX...",
"fields": [{ "field": "title", "message": "String must contain at least 1 character(s)" }]
}
Variant B — a thrown HttpError. Its payload rides under data:
{
"error": "bad_request",
"error_description": "Bad request",
"status": 400,
"request_id": "req_01HX...",
"data": { "fields": [{ "field": "title", "message": "Required" }] }
}
Those two shapes are the whole story for a 400. BackendError.data carries variant B's data object; nothing on BackendError carries variant A's top-level fields.
Server-side validation does not arrive as kind: 'validation'
This is the correction most worth internalizing, because the previous version of this page taught the opposite.
Rule 4 keys on a nested details array. The backend engine does not emit one — a boundary validation failure puts its field list at the envelope's top level, and no Palbase service on this platform emits a wire details field-error array at all. So when your @Body schema rejects a request, the caller sees:
| Property | Value |
|---|---|
e.kind | 'server' |
e.code | 'bad_request' |
e.status | 400 |
e.fields | undefined |
e.message | Request body failed validation, or Query parameters failed validation |
try {
await pb.todos.create({ title: '' });
} catch (e) {
if (isBackendError(e) && e.code === 'bad_request') {
// e.fields is undefined here. The per-field list is in the envelope's
// top-level `fields`, which this SDK does not surface.
showToast(e.message);
}
}
If you want per-field messages a browser can act on, throw them yourself from the handler, so they arrive under data where the SDK does surface them:
// in your backend handler
throw new BadRequest({ fields: [{ field: "title", message: "Required" }] });
// in the browser — e.kind is 'server', e.code is 'bad_request'
if (isBackendError(e) && e.code === 'bad_request') {
const fields = (e.data as { fields?: FieldError[] } | undefined)?.fields ?? [];
for (const f of fields) setFieldError(f.field, f.message);
}
BadRequest's payload shape is declared once in the backend SDK precisely so that codegen can type error.data.fields on the client. See Responses & Errors and Validation.
kind: 'validation' with fields populated is, in practice, always a client-side check — a bad query value, a missing path parameter, an upload constraint. Treat that branch as "the browser refused", not "the server refused".
Rate limits: where retryAfter comes from, and where it does not
e.kind === 'rateLimited' is reliable for every 429. e.retryAfter is not, because rule 3 reads retry_after from the response body rather than from the Retry-After header, and three different limiters answer a Palbase request with three different bodies:
| Which 429 | e.code | e.retryAfter | e.data |
|---|---|---|---|
| Auth throttling — sign-in, sign-up, password reset, MFA | account_locked, or the limiter's own code | the seconds | — |
| Your own endpoints, limited per route by the engine | too_many_requests | undefined | — |
new TooManyRequests({ retryAfter }) thrown by your handler | too_many_requests | undefined | { retryAfter } |
The auth module writes a top-level retry_after beside the header, so a locked-out sign-in really does hand you the number. The engine's per-route limiter sends the header only, and a TooManyRequests you threw is an HttpError like any other — its payload lands under data. Read defensively:
if (isBackendError(e) && e.kind === 'rateLimited') {
const hinted = e.retryAfter ?? (e.data as { retryAfter?: number } | undefined)?.retryAfter;
retryIn(hinted ?? 1);
}
Remember the SDK has already retried before you see this: up to 3 attempts honouring Retry-After, clamped to 10 s per sleep. A 429 that reaches your catch survived all of them.
Use isBackendError, not instanceof BackendError
import { isBackendError } from '@palbase/web';
if (isBackendError(e)) {
console.log(e.kind, e.code, e.status, e.requestId);
}
Warning: Always prefer
isBackendError(e). The package ships dual ESM + CJS builds; if both end up loaded in one process — or the package is installed twice in a monorepo — two distinctBackendErrorclasses coexist andinstanceofsilently fails for errors thrown by the other copy.isBackendErrordoes aninstanceoffirst and then falls back to a structural check onname === 'BackendError'andtypeof kind === 'string', so it survives the split.
Generated typed error classes
When your backend declares an error on an endpoint, codegen emits a dedicated class for it in palbe.gen.ts:
export class RoomsCreateRoomLockedError extends Error {
readonly name = 'RoomsCreateRoomLockedError';
readonly code = 'room_locked';
readonly status = 409;
readonly data: RoomsCreateRoomLockedData; // typed, when the error declares a payload
readonly cause: BackendError; // the original wire error
constructor(cause: BackendError) {
super(cause.message); this.cause = cause; this.data = cause.data as RoomsCreateRoomLockedData;
}
}
The class name is <Namespace><Operation><ErrorName>Error. The descriptor for that operation carries a lift map from wire code to constructor, so when the endpoint returns that code the SDK converts the BackendError before it reaches your catch:
import { RoomsCreateRoomLockedError } from './palbase/client';
try {
await pb.rooms.create({ name: 'lobby', kind: 'public' });
} catch (e) {
if (e instanceof RoomsCreateRoomLockedError) {
console.log(e.code, e.status, e.data.locked_until, e.cause.requestId);
}
}
Four things to know:
- They extend
Error, notBackendError.isBackendError(lifted)isfalse. The original error, withrequestId,statusandkind, is always one.cause. instanceofis safe here, unlike forBackendError. Each class is defined exactly once, in your ownpalbe.gen.ts, so there is no dual-package identity split.- Lifting is per endpoint. A code the other endpoint declares does not lift on this one.
- Unmapped codes stay
BackendError. If the deployed backend is newer than your last codegen run, the new code arrives as a plainBackendErrorwith the wirecodeintact:
catch (e) {
if (e instanceof RoomsCreateRoomLockedError) { /* typed path */ }
else if (isBackendError(e) && e.code === 'room_locked') { /* same code, older gen file */ }
else throw e;
}
The emitter refuses to generate a class that would shadow an SDK infrastructure code, and it drops duplicates: errors are sorted by wire code, __proto__ is dropped, infra-reserved codes are dropped, and duplicate wire codes or duplicate class names resolve first-wins.
Note: Regenerate
palbe.gen.tsafter every backend deploy so the classes stay in sync. Thepredev/prebuildhook thatpalbase linkinstalls does this on every dev run and build.
Codes the SDK itself throws
These originate in @palbase/web, not on the wire, so they always arrive as a plain BackendError. The Reserved column says whether the emitter also refuses to generate a typed class for that code — see the reserved-code set below, which is neither a superset nor a subset of this table.
code | kind | Thrown by | Reserved |
|---|---|---|---|
not_configured | notConfigured | any pb use before the generated file was imported | yes |
network_error | network | the transport, after its retries — including a typed call cancelled through signal | yes |
aborted | network | an upload cancelled through its AbortSignal | yes |
decode_error | decode | an auth union or upload response that would not parse | yes |
invalid_endpoint_name | validation | pb.call('') | yes |
unsupported_get_input | validation | a body passed to a GET descriptor | yes |
missing_path_param | validation | a path argument that is not a string or number | yes |
unexpected_argument | validation | an extra argument on an input: 'none' operation | yes |
invalid_query_value | validation | a query value that is not a string, number or boolean | yes |
reserved_namespace | validation | registering a namespace that collides with a reserved name | yes |
invalid_namespace_tree | validation | a malformed descriptor at registration | yes |
validation_error | validation | palbeProxy given a non-publishable key | yes |
http_error | varies | the fallback code when an envelope carries no error | yes |
file_too_large | validation | pb.upload constraints.maxSize | no |
file_type_not_allowed | validation | pb.upload constraints.allowedTypes | no |
invalid_flag_name | validation | pb.flags.getVariant() — a bad name, or any thrown transport failure | no |
realtime_unavailable | validation | pb.realtime.channel() on a server runtime | no |
next_required | validation | pbServer / handleAuthCallback / palbeProxy outside Next.js | no |
google_not_configured, apple_not_configured | validation | the provider shortcuts without matching oauth config | no |
invalid_group_id | validation | pb.calls.start('') | no |
no_keypackage | validation | chat.addMemberUser() for a user with no claimable key package | no |
not_a_member | validation | chat.removeMemberUser() for a user with no device in the group | no |
The two upload-constraint codes also populate fields with a single entry: [{ field: 'file', message }].
The reserved-code set
The emitter reserves exactly fifteen codes and will not emit a typed class for any of them. Thirteen are in the table above; the remaining two, unauthorized and rate_limited, are not SDK-thrown codes at all — they are reserved because they collide with the unauthorized and rateLimited kinds the SDK derives from 401 and 429.
not_configured network_error decode_error validation_error
unauthorized rate_limited invalid_endpoint_name unsupported_get_input
missing_path_param unexpected_argument invalid_query_value
reserved_namespace invalid_namespace_tree aborted http_error
Warning: declaring a backend error whose code is one of those fifteen —
@Throwswith codeunauthorized, say — silently costs you the typed class. The emitter drops it with a// codegen: skipped error code "unauthorized" (reserved infra code)comment in the generated file and nothing else. The error still arrives, as aBackendErroryou must match oncode. Everything not on that list, including every code marked "no" in the table above, does get a generated class if your backend declares it.
Proof of work
The platform can answer any request with 403 and a body of {"error":"pow_required","challenge":{…}}. The SDK solves the challenge and repeats the request once, so you normally never see it. You do see it in two cases, and both arrive as ordinary errors rather than hangs:
- the response says
pow_requiredbut carries no challenge — not retried, because looping on a broken gate would turn it into a hang; - the solver refuses: a difficulty above 24, or a machine-calibrated estimate exceeding the 120 s time budget. The refusal names the numbers rather than grinding.
See Overview.
A revoked session is not a 401 you can catch
Three wire answers tear the local session down instead of surfacing normally:
| Response | Effect |
|---|---|
401 with code session_revoked | session cleared, signedOut with reason sessionInvalid |
403 with code subject_fenced | same |
403 with code subject_erased | same |
Everything else, including a plain 403 from your own authorization check or a Row-Level Security denial, is not a kill — it arrives as kind: 'server' with status: 403. Handle sign-out in one place by listening for the event rather than by inspecting statuses:
pb.auth.onAuthEvent((e) => {
if (e.type === 'signedOut' && e.reason === 'sessionInvalid') router.push('/login');
});
kind: 'unauthorized' therefore means "a 401 that survived one refresh and one retry" — see Auth.
requestId and support
Every error that came from a server response carries a requestId. Log it and quote it in a bug report; it identifies the exact request in your project's traces.
if (isBackendError(e) && e.requestId) {
console.error(`Request ${e.requestId} failed: ${e.code} (${e.status})`);
}
Related
- Calling Your Backend — the pipeline that produces these errors
- Overview — retries, proof of work, and the full header table
- Uploads —
file_too_large,file_type_not_allowed,aborted - Auth — the refresh-and-retry that a
401has already survived - Codegen — where the typed error classes come from
- Responses & Errors — declaring these errors on the server
- Validation — the schemas that produce a 400