Documentation
TroubleshootingError handlingHandling errors

Handling errors

Which failures your code retries, which need the user, and which are yours to fix.

Every failure arrives in the same shape, so the interesting question is not how to read one — the reference covers that — but what to do with it.

There are only four answers, and status_code tells you which one applies before you ever look at code.

Branch on the status first

400, 413, 422Your bug

The request was malformed or the data was rejected. Retrying changes nothing.

401Sign in again

No valid session. Send them to sign-in, not to an error page.

403Ask the user for something

Authenticated, but missing a permission, a confirmation, or a second factor.

429, 503Wait and retry

Nothing is wrong with the request. Back off.

Only then does code matter — it says which of those, so you can write a useful message instead of "something went wrong".

const { data, error } = await nulvless.auth.signIn({ email, password });

if (error) {
  if (error.status_code === 401) return goToSignIn();
  if (error.status_code === 429) return backOff(error);

  return showMessage(messageFor(error.code) ?? error.message);
}

The 403 family is the interesting one

A 403 almost never means "give up". It means the user can do something about it, and the code says what:

protected_action_deniedConfirm identity

Sudo mode is required. Start the sudo flow and retry.

mfa_requiredEnrol a factor

The environment requires MFA and this session never used one.

user_not_confirmedFinish sign-up

The address or phone was never confirmed.

forbidden_access_waiting_acceptAccept the invitation

A grant exists but is still pending — it is in their inbox.

forbidden_resource_accessGenuinely not allowed

No grant on the resource, or a missing scope or role.

Collapsing all of these into one "Access denied" screen is the most common mistake, and it strands people who were one click away from continuing.

Let events handle sudo

protected_action_denied is common enough that the SDK emits an event for it, so you do not have to catch it at every call site:

nulvless.auth.on('sudo-required', async () => {
  await nulvless.auth.sessions.sudo();
  openSudoPrompt();
});

There is an unauthorized event for the 401 case as well. See Events.

Backing off

Two codes mean "not now", and they behave differently:

if (error.code === 'rate_limit_exceeded' || error.code === 'service_overloaded') {
  await wait(backoff());
}

The SDK does not surface Retry-After

NulvlessError carries exactly three fields — code, message and status_code. The auth service does put a retryAfter in the rate-limit body, and service_overloaded does come with a Retry-After header, but neither survives into the error object. Use your own backoff, or read the raw response if you need the hint.

Never retry a 400, 413 or 422. The request will fail the same way every time.

Validation errors arrive as a list

message is string | string[] — an array when several fields failed at once. Flatten it in one place:

const text = Array.isArray(error.message) ? error.message.join(', ') : error.message;

Sign-up is where you will meet this first: a request can be rejected for the email and the phone in the same response.

Show the code when you cannot explain it

For anything you have not written a message for, showing the raw code beats a generic apology — it is what your support team will ask for, and it is stable across releases in a way that message text is not.

The platform attaches a reference to an unclassified deploy failure (internal_error) so support can find the log line. Like retryAfter, it is in the response body rather than on NulvlessError, so reach for the raw response if you want to show it.

Some errors never reach the network

A few codes are produced by the SDK itself, before or instead of a request. They have the same shape, so the same branch handles them:

no_session401

A call needing a session was made with none stored.

request_timeout408

The timeout you configured elapsed.

network_error0

The request never completed. status_code is 0 — nothing answered.

unknown_error

A response arrived that did not parse as an error payload.

status_code: 0 is the reliable test for "offline or unreachable", and it is worth its own message.

Edge functions answer 546

If you call an edge function from your own code, three failures arrive with a 546 status rather than a 5xx you would recognise:

if (response.status === 546) {
  const { code } = await response.json();
  // execution_timeout | cpu_time_limit_exceeded | memory_limit_exceeded
}

It is deliberately outside the standard range so a limit violation is never mistaken for a server error. A plain 500 with no code in the body means the opposite — your function threw, and the platform is not claiming to know why.

Codes are scoped per service

The same string can be defined in more than one service, and a code you see depends on which one answered. unauthorized from the auth service and unauthorized from the platform API mean the same thing in spirit, but they are separate definitions.

The Exception codes page lists them by scope, and only the ones actually in use.

Next

On this page