Web SDK

Error Handling

Every call through the client returns its answer or throws. There is no { data, error } pair to unpack. What it throws for a refusal is a PalbaseError, the one error class of the Palbase SDKs, exported from palbase/web and palbase/server alike. It carries the HTTP status, the module's machine-readable code, its words, the request's id and, when the server said so, how long to wait. Before it throws, the client has already done what can be done without your code: it refreshed a token that was refused before its time, solved auth's proof-of-work, and waited out a "not now" on a call that is safe to send twice.

Quick example

// src/lib/palbase.ts
import { createClient } from "palbase/web";

export const pb = createClient({ url: "https://k3xq81w4m.palbase.studio", publishableKey: "pb_k3xq81w4m_cZ8yX7wV6uT5" });
// src/login.ts
import { PalbaseError } from "palbase/web";
import { pb } from "./lib/palbase";

export async function login(email: string, password: string): Promise<string> {
  try {
    const result = await pb.auth.signIn(email, password);
    return result.mfaRequired ? "enter your second factor" : "signed in";
  } catch (e) {
    if (!(e instanceof PalbaseError)) throw e;
    if (e.status === 401) return "wrong email or password";
    if (e.status === 429) return `too many tries; wait ${e.retryAfter ?? 60} seconds`;
    if (e.status === 0) return "you are offline";
    console.error(e.op, e.status, e.code, e.message, e.requestId);
    return "something went wrong";
  }
}

PalbaseError

FieldWhat it is
statusThe HTTP status. 0 for a call that never got an answer (see below).
codeThe envelope's error: invalid_token, rate_limited, pow_required, … When the answer carried no envelope it is http_<status>.
messageThe envelope's error_description, in words a person can read.
requestIdThe request's id (request_id, or the x-request-id header). Quote it to find the call in logs.
retryAfterSeconds the server asked to wait (Retry-After, or the envelope's retry_after), when it asked.
bodyThe refusal's body as sent, read up to 64 KiB. Useful for answers that are not the envelope (OAuth errors, a proof-of-work challenge).
opThe operation that was refused (Login, RefreshToken, WebauthnLoginFinish, …).
causeThe underlying error, for a call that failed before an answer.

Check it with instanceof PalbaseError. The modules' typed calls (pb.auth.api, pb.flags.api) throw the same class. A call to your own backend through pb.<backend> refuses with a BackendError instead: the same envelope's code, status, requestId and retryAfter, with a kind to switch on and the fields that failed. See Calling Your Backend.

The envelope

Every module and every palbase/backend app refuses in one shape:

{
  "error": "rate_limited",
  "error_description": "Too many attempts; try again later",
  "status": 429,
  "request_id": "req_4b1f0c7e-…",
  "retry_after": 60
}

status mirrors the response's status. retry_after is there on a refusal that ends with time, and the Retry-After header says the same. A palbase/backend app may add data, such as the fields that failed their schema. PalbaseError keeps the whole body in body.

What the client retries

A call is sent again only when sending it again cannot do the work twice:

  • Which calls. Reads (GET, HEAD), and writes whose contract takes an Idempotency-Key. For those the client makes one key per call and every attempt carries the same one, so the server answers a repeat with the first answer and changes nothing. A write without one, a sign-in for example, is never sent twice by the client.
  • Which answers. 429, 502, 503, 504, and a call that never reached the server (network_error). Every other answer is yours.
  • How long. Three attempts in all, the first included. When the server said how long to wait, the client waits exactly that, and a Retry-After over 30 seconds is not waited for: the error comes to you with retryAfter set. When it said nothing, the client waits 0.5 s, then 1 s, with a little jitter.

Change it per client:

// src/lib/patient.ts
import { createClient } from "palbase/web";

// Five attempts, and wait up to two minutes when the server asks.
export const pb = createClient({
  url: "https://k3xq81w4m.palbase.studio",
  publishableKey: "pb_k3xq81w4m_cZ8yX7wV6uT5",
  retry: { attempts: 5, maxWaitSeconds: 120 },
});

{ attempts: 1 } turns retries off. A call you abort is not retried.

A refused token

A call that acts for the user and is refused 401 with a code that means "this token is not good any more" (invalid_token, token_expired, session_expired, session_revoked, missing_token) gets one refresh of the session, and is sent once more, only if the session is still the same user's. Every other 401, such as a wrong password, a wrong code or a wrong current password, is the answer and goes to you as it came.

When the refresh itself is refused, because the refresh token was used twice, revoked or expired, the session is over. The client clears it, tells your app SESSION_EXPIRED through pb.auth.onAuthStateChange, and the call's error reaches you. Send the user to sign in from the listener, not from every catch. See Auth.

A session that changed under a refresh (another tab signed out, or signed in as someone else) throws an error whose code is session_changed, and the call that asked is not sent again. Read it by code: it is not a PalbaseError.

Proof of work

Auth may answer a sign-in, a sign-up or a code request 403 pow_required with a challenge, when the address it comes from looks like a bot. The client solves it and sends the same request once more with the answer. You see nothing of it unless solving fails, which comes back as 403 pow_unsolved. A request whose body cannot be read twice (a stream) is not sent again, and the pow_required error comes to you.

Errors the client makes itself

These never came from a server:

statuscodeWhen
0network_errorThe request did not reach the project: offline, DNS, a refused connection, or a cross-origin call the browser blocked. Retried like a 503.
0missing_credentialThe operation needs what the client does not hold: a signed-in user, or the secret key for an /admin/* call. Nothing was sent.
0invalid_path, invalid_path_parameterA path parameter that is empty, . or .., or a path that would leave the project's address. Nothing was sent.
2xxundeclared_answerThe server answered a success the contract does not declare. The request may have taken effect.
3xxundeclared_redirectThe server answered a redirect. The client never follows one, because a redirect would carry the key to whatever host it names.
anyinvalid_jsonAn answer the contract says is JSON was not.

createClient itself throws a TypeError, not a PalbaseError, for a secret key, for anything that is not a publishable key, and for a url that is not an https origin (http only for a loopback address).

network_error is also what a browser's CORS refusal looks like from code: the browser hides why. The project's address answers a page on any site, its refusals included, so a call the client makes is not refused this way (see Overview). A machine's address answers only the origins its backend names: see Calling Your Backend.

Codes worth handling by name

Wherestatus / codeWhat to do
Sign-in401Wrong email or password. Say so without saying which.
Sign-in429 with retryAfterThe account is locked for a while after repeated failures, or the address is limited. Show the wait.
Sign-up, password400A password the policy refuses or one known from a breach; message says which.
A code (MFA, phone, email)401A wrong or used code.
Social sign-in410 auth_transaction_expiredThe transaction expired. Start again.
Any module429Over the project's request rate or a module's quota. Honour retryAfter.
Any module503The platform could not answer now. The client already retried a safe call.