BetterIAM

Error handling

How Better IAM reports failures with IamError and its stable codes, how codes map to HTTP statuses, how to handle them on the server and in the client, and which ones are safe to retry.

better-iam@better-iam/core@better-iam/server@better-iam/client@better-iam/middlewareindex.tshttp.tsoperations.tshosts.tsquotas.tssignal-receiver.tsbase.tsindex.tssession.tsindex.tserrors.tsfilter.tserrors.json

Every failure Better IAM reports is an IamError with three fields: a stable code such as ACCESS_DENIED, a message written for people, and an HTTP status. Codes are part of the public contract and do not change between releases. Messages can be reworded at any time and can contain names from your data, so show them to people but never parse them: branch on code.

The same code reaches you whichever way you call: thrown as an IamError from a direct iam.api call, returned in the JSON error envelope over HTTP, and rethrown as an IamClientError by the typed client. The error reference explains each of the 183 codes.

The error shape

IamError (from better-iam)
class IamError extends Error {
  readonly code: string; // stable, for example 'ACCESS_DENIED'
  readonly status: number; // the HTTP status, 400 unless the code says otherwise
  message: string; // for people; never parse it
}

Import it from better-iam, better-iam/server, or better-iam/core (the same class in each). Over HTTP, a refusal answers with status and this body:

{ "error": { "code": "ACCESS_DENIED", "message": "Access denied" } }

RATE_LIMITED and QUOTA_EXCEEDED add retryAfterMs to the body and a Retry-After header in seconds. WRONG_REGION adds region and, when one can be built, location.

Error classes

Most failures are plain IamErrors. A few subclasses carry extra fields, and the client and framework adapters have their own classes with the same code and status.

ClassImport fromExtendsExtra fields
IamErrorbetter-iam, better-iam/server, better-iam/coreErrorcode, status
WrongRegionErrorbetter-iam/serverIamErrorregion, location (code WRONG_REGION, status 421)
QuotaExceededErrorbetter-iam/serverIamErrordecision, retryAfterMs (code QUOTA_EXCEEDED, status 429)
SignalRejectedErrorbetter-iam/serverIamErrorerr (the RFC 8935 error code), temporary (code SIGNAL_REJECTED)
CliErrorbetter-iam/cliIamErrorhint: one sentence naming the next step
IamClientError (alias ClientError)better-iam/clientErrorcode, status, retryAfterMs, requestId, region, location
IamRequestErrorbetter-iam/middlewareErrorcode, status, reason (mfa, recent, or impersonation for step-up refusals)

RATE_LIMITED errors thrown on the server are plain IamErrors with a retryAfterMs property attached: read it as (error as IamError & { retryAfterMs?: number }).retryAfterMs.

IamClientError does not extend IamError, because the client package carries no server code. When a value may be either, use the duck-typed helpers from better-iam/middleware: errorCode(error), errorStatus(error), and isIamRefusal(error), which is true for anything with a string code and a numeric status.

Status codes

The status tells a generic HTTP client what kind of failure it is; the code tells your application what to do.

StatusMeaningCommon codes
400The input is invalid, or the operation is not allowed in the record's current state. Retrying the same request fails the same way.INVALID_INPUT, INVALID_POLICY, WEAK_PASSWORD, INVITATION_INVALID
401No usable credential, or the credential presented to sign in is wrong.UNAUTHENTICATED, INVALID_CREDENTIALS, INVALID_MFA, SESSION_NETWORK_MISMATCH
402An enforced spend budget is used up.SPEND_LIMIT_REACHED
403The caller is known but may not do this, or must step up first.ACCESS_DENIED, MFA_REQUIRED, RECENT_AUTH_REQUIRED, CSRF_REJECTED, TENANT_INACTIVE
404The record or route does not exist.NOT_FOUND, TENANT_NOT_FOUND
409The change conflicts with existing data or a concurrent change.CONFLICT, VERSION_CONFLICT, SLUG_TAKEN, LAST_OWNER, SOD_CONFLICT
413The HTTP body is over 64 KiB.PAYLOAD_TOO_LARGE
421Another region's deployment serves this organization.WRONG_REGION
429Too many attempts, or a usage plan is exhausted.RATE_LIMITED, QUOTA_EXCEEDED, TOO_MANY_REQUESTS
500An unexpected failure or a storage problem.INTERNAL_ERROR, STORAGE_ERROR
502An upstream service failed.WEBHOOK_UNREACHABLE, WEBHOOK_REJECTED, OAUTH_DISCOVERY
503A dependency is temporarily unavailable.STORAGE_BUSY, PASSWORD_CHECK_UNAVAILABLE

A few codes are raised with more than one status in different places (for example ACCESS_DENIED is 400 in a few validation paths), which is one more reason to branch on code.

Over HTTP, an exception that is not an IamError (a bug, or one of your callbacks such as resolveResource throwing) becomes 500 INTERNAL_ERROR with a generic message, so internals never leak. A direct server call throws the original exception instead.

Usage

Handle errors on the server

Catch around the call and compare code. Rethrow anything you do not handle.

routes/documents.ts
import { IamError } from 'better-iam';
import { iam } from '../iam';

export async function updateDocument(request: Request, document: { id: string; tenantId: string }) {
  try {
    await iam.require({
      headers: request.headers,
      tenantId: document.tenantId,
      action: 'documents:write',
      resource: { type: 'document', id: document.id },
    });
  } catch (error) {
    if (error instanceof IamError && error.code === 'UNAUTHENTICATED') return Response.redirect('/login', 303);
    if (error instanceof IamError && error.code === 'ACCESS_DENIED')
      return Response.json({ error: { code: error.code, message: error.message } }, { status: 403 });
    throw error;
  }
  // ...the caller may write the document
  return Response.json({ ok: true });
}

A refused provisioning operation changes nothing: it runs in one transaction, and a failure rolls the whole transaction back. The only records a refusal leaves are the ones it is meant to leave: the deny audit event of an authorization denial, and the attempt counters of rate-limited sign-in, recovery, and MFA flows.

Turn errors into HTTP responses

When you call iam.api from your own routes, answer refusals with the same envelope and status the IAM handler uses, so one piece of client code handles both.

lib/iam-errors.ts
import { IamError } from 'better-iam';

export function errorResponse(error: unknown): Response {
  if (!(error instanceof IamError)) {
    console.error(error); // log the details; never send them to the caller
    return Response.json(
      { error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } },
      { status: 500 },
    );
  }
  const headers = new Headers({ 'cache-control': 'no-store' });
  const retryAfterMs = (error as IamError & { retryAfterMs?: number }).retryAfterMs;
  if (retryAfterMs) headers.set('retry-after', String(Math.ceil(retryAfterMs / 1000)));
  return Response.json({ error: { code: error.code, message: error.message } }, { status: error.status, headers });
}

The framework integrations ship this for you:

IntegrationHandler
Expressapp.use(iamExpress.errorHandler), registered last
Honoapp.onError(iamHono.onError)
Fastifyapp.setErrorHandler(iamFastify.errorHandler)
NestJSIamExceptionFilter, installed globally

The Express, Hono, and Fastify adapters answer page navigations with a redirect to your login or step-up page when you configure one, and everything else with the JSON envelope. The NestJS filter always answers with the envelope. See Express, Hono, Fastify, and NestJS. On a custom server, refusalResponse from better-iam/middleware does the same (Node.js and custom servers).

Handle errors in the client

The typed client throws IamClientError for every refusal and for problems it detects itself. Network failures and aborted calls reject with Fetch's own error (a TypeError or an AbortError) unchanged.

app/invite.ts
import { IamClientError } from 'better-iam/client';
import { client } from './iam-client';

try {
  await client.identities.invite({ tenantId, email });
} catch (error) {
  if (!(error instanceof IamClientError)) throw error; // network failure or aborted call
  switch (error.code) {
    case 'ACCESS_DENIED':
      showMessage('You do not have permission to invite people.');
      break;
    case 'RECENT_AUTH_REQUIRED':
      openReauthenticateDialog();
      break;
    case 'RATE_LIMITED':
      showMessage(`Try again in ${Math.ceil((error.retryAfterMs ?? 60_000) / 1000)} seconds.`);
      break;
    default:
      showMessage(`Something went wrong (reference ${error.requestId ?? 'none'}).`);
  }
}
  • Signed out. Set onUnauthenticated when you create the client to send people to the login page in one place. It fires only for UNAUTHENTICATED and SESSION_NETWORK_MISMATCH, never for a wrong password or a step-up code.
  • Session store. isUnauthenticated(error) from better-iam/client/session is true for 401 and 403 errors, the rule the session store uses to tell "signed out" from a transport failure.
  • Wrong region. For WRONG_REGION, send the person to error.location when it is set.
  • Client-side codes. INVALID_CONFIG, INVALID_TOKEN, INVALID_PATH, INVALID_INPUT, INVALID_RESPONSE, and HTTP_ERROR come from the client itself, with status 0 when no request was sent. See Typed client.

Map codes to what the person does next

A UI rarely needs one branch per code. A few groups cover most screens:

Next stepCodes
Sign in againUNAUTHENTICATED, SESSION_NETWORK_MISMATCH
Step upMFA_REQUIRED, RECENT_AUTH_REQUIRED
Fix the formINVALID_INPUT, INVALID_CREDENTIALS, WEAK_PASSWORD, BREACHED_PASSWORD
WaitRATE_LIMITED, QUOTA_EXCEEDED
Ask for accessACCESS_DENIED
Reload, then decide againCONFLICT, VERSION_CONFLICT, INVALID_TRANSITION
Show a generic failure with the request IDEverything else

The error reference has a complete nextStep(error) function built on these groups.

Retrying

Retry only failures that can succeed later without a change from the caller. Everything else fails the same way on every attempt.

CodeStatusRetry?
RATE_LIMITED429After retryAfterMs (the window length, so the longest wait). The client can do this once for short waits with retryRateLimited.
QUOTA_EXCEEDED429After retryAfterMs. Without retryAfterMs, the call costs more than the plan ever allows: do not retry.
STORAGE_BUSY503Yes, the complete operation after a short pause. The transaction was rolled back.
PASSWORD_CHECK_UNAVAILABLE503Yes, shortly. The breached-password service could not be reached.
INTERNAL_ERROR500Once. Then find the request by its request ID in your logs.
Network failure (TypeError)noneFor a read, yes. For a change, the outcome is unknown: read the current state first, then decide.
TOO_MANY_REQUESTS429No. Too many pending access requests; waiting alone does not help.
CONFLICT, VERSION_CONFLICT409Not as is. Reload the record and decide again.
Any other 4xx4xxNo. Change the input, the caller's access, or the order of operations.

Better IAM has no general idempotency keys, so a change retried after a lost response can run twice. Many changes then fail harmlessly with a conflict code (CONFLICT, SLUG_TAKEN, IDENTITY_EXISTS), but read back before retrying changes that would not.

On the server, a small wrapper covers STORAGE_BUSY:

lib/retry.ts
import { IamError } from 'better-iam';

/** Runs `operation` again when the database was too busy; every other error is rethrown at once. */
export async function retryWhenBusy<T>(operation: () => Promise<T>, attempts = 3): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    try {
      return await operation();
    } catch (error) {
      if (!(error instanceof IamError) || error.code !== 'STORAGE_BUSY' || attempt >= attempts) throw error;
      await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt));
    }
  }
}
jobs/provision.ts
import { iam } from '../iam';
import { retryWhenBusy } from '../lib/retry';

const service = { token: process.env.BETTER_IAM_TOKEN! }; // an API key of a service account
const tenantId = process.env.TENANT_ID!;
const group = await retryWhenBusy(() => iam.api.groups.create(service, { tenantId, name: 'Finance' }));

If STORAGE_BUSY is frequent, raise the adapter's lockTimeoutMs (PostgreSQL) or busyTimeoutMs (SQLite, libSQL) instead; see storage operations.

Request IDs

A request ID ties an error a person reports to your logs. Create the client with requestId: true (or a function returning your own trace ID); it sends X-Request-Id on every call, the server echoes it and records it on the request's http observability span, and the client exposes it as IamClientError.requestId. Show it in generic error messages and ask for it in support tickets. See observability.

CLI errors

The better-iam command prints CODE: message to standard error, often followed by a Hint: line, and exits with status 2 for a command-line mistake (INVALID_ARGUMENT, INVALID_COMMAND) and 1 otherwise. From code, runCli rejects with the IamError or CliError itself. See CLI.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page