Palbase
Sign inGet started

Web SDK

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.details on this class, and there are no 'forbidden' or 'conflict' kinds. A 403, a 409 and a 503 all arrive as kind 'server', discriminated by err.code. If you have code branching on e.kind === 'forbidden' or reading e.details, it has never run.

The seven kinds

type BackendErrorKind =
  | 'notConfigured' | 'validation' | 'unauthorized'
  | 'rateLimited'   | 'server'     | 'network' | 'decode';
kindWhenUseful properties
notConfiguredpb was used before the palbase/client barrel was importedcode: 'not_configured'
validationA client-side check failed, before the request was sentcode, fields
unauthorizedHTTP 401, after the automatic refresh-and-retry failedstatus, requestId
rateLimitedHTTP 429, after the SDK's internal retries were exhaustedcode, retryAfter, data
serverEverything else non-2xx — 400, 403, 404, 409, 5xx — plus status-0 non-network errorscode, status, data, requestId
networkNo response at all: offline, DNS failure, a cancelled callcode'network_error', or 'aborted' for a cancelled upload
decodeA 2xx response whose body was not valid JSONstatus

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:

  1. code === 'network_error'network.
  2. status === 401unauthorized.
  3. status === 429rateLimited, with retryAfter read from the body's top-level retry_after.
  4. status === 400 and the envelope's nested details key is a non-empty array of { field, message }validation, with that array on fields.
  5. 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:

PropertyValue
e.kind'server'
e.code'bad_request'
e.status400
e.fieldsundefined
e.messageRequest 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 429e.codee.retryAftere.data
Auth throttling — sign-in, sign-up, password reset, MFAaccount_locked, or the limiter's own codethe seconds
Your own endpoints, limited per route by the enginetoo_many_requestsundefined
new TooManyRequests({ retryAfter }) thrown by your handlertoo_many_requestsundefined{ 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 distinct BackendError classes coexist and instanceof silently fails for errors thrown by the other copy. isBackendError does an instanceof first and then falls back to a structural check on name === 'BackendError' and typeof 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, not BackendError. isBackendError(lifted) is false. The original error, with requestId, status and kind, is always on e.cause.
  • instanceof is safe here, unlike for BackendError. Each class is defined exactly once, in your own palbe.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 plain BackendError with the wire code intact:
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.ts after every backend deploy so the classes stay in sync. The predev/prebuild hook that palbase link installs 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.

codekindThrown byReserved
not_configurednotConfiguredany pb use before the generated file was importedyes
network_errornetworkthe transport, after its retries — including a typed call cancelled through signalyes
abortednetworkan upload cancelled through its AbortSignalyes
decode_errordecodean auth union or upload response that would not parseyes
invalid_endpoint_namevalidationpb.call('')yes
unsupported_get_inputvalidationa body passed to a GET descriptoryes
missing_path_paramvalidationa path argument that is not a string or numberyes
unexpected_argumentvalidationan extra argument on an input: 'none' operationyes
invalid_query_valuevalidationa query value that is not a string, number or booleanyes
reserved_namespacevalidationregistering a namespace that collides with a reserved nameyes
invalid_namespace_treevalidationa malformed descriptor at registrationyes
validation_errorvalidationpalbeProxy given a non-publishable keyyes
http_errorvariesthe fallback code when an envelope carries no erroryes
file_too_largevalidationpb.upload constraints.maxSizeno
file_type_not_allowedvalidationpb.upload constraints.allowedTypesno
invalid_flag_namevalidationpb.flags.getVariant() — a bad name, or any thrown transport failureno
realtime_unavailablevalidationpb.realtime.channel() on a server runtimeno
next_requiredvalidationpbServer / handleAuthCallback / palbeProxy outside Next.jsno
google_not_configured, apple_not_configuredvalidationthe provider shortcuts without matching oauth configno
invalid_group_idvalidationpb.calls.start('')no
no_keypackagevalidationchat.addMemberUser() for a user with no claimable key packageno
not_a_membervalidationchat.removeMemberUser() for a user with no device in the groupno

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 — @Throws with code unauthorized, 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 a BackendError you must match on code. 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_required but 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:

ResponseEffect
401 with code session_revokedsession cleared, signedOut with reason sessionInvalid
403 with code subject_fencedsame
403 with code subject_erasedsame

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})`);
}
  • Calling Your Backend — the pipeline that produces these errors
  • Overview — retries, proof of work, and the full header table
  • Uploadsfile_too_large, file_type_not_allowed, aborted
  • Auth — the refresh-and-retry that a 401 has 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