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 bugThe request was malformed or the data was rejected. Retrying changes nothing.
401Sign in againNo valid session. Send them to sign-in, not to an error page.
403Ask the user for somethingAuthenticated, but missing a permission, a confirmation, or a second factor.
429, 503Wait and retryNothing 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 identitySudo mode is required. Start the sudo flow and retry.
mfa_requiredEnrol a factorThe environment requires MFA and this session never used one.
user_not_confirmedFinish sign-upThe address or phone was never confirmed.
forbidden_access_waiting_acceptAccept the invitationA grant exists but is still pending — it is in their inbox.
forbidden_resource_accessGenuinely not allowedNo 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_session401A call needing a session was made with none stored.
request_timeout408The timeout you configured elapsed.
network_error0The request never completed. status_code is 0 — nothing answered.
unknown_errorA 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.