Error handling

Patterns for catching SDK errors. Telling a missing permission from a rule, expiry from a network failure, when to retry, when to sign in again, and a reusable handler.

The SDK throws typed errors, and each one means something different for what your app should do next. This page gives the decisions and a handler you can copy. For the full list of classes and codes, see the error reference.

The classes in one table

All errors extend UnetIdError, which has a string code. Check the specific classes first.

ErrorMeansWhat the app does
SessionExpiredThe session is gone: expired without a refresh token, or the person removed your app. The SDK has already cleared it.Show the signed-out state and offer to sign in. Not a bug.
InsufficientScopeThe session lacks the permission the call needs (scope names it).Ask for it with signIn({ prompt: "consent" }) if your app should have it, or hide the feature.
RateLimitedToo many requests (retryAfterSeconds may be null).Wait, then try once. Tell the person if it takes long.
ApiErrorUNET refused or failed: status and code say how.Depends on code; see below.
NetworkErrorUNET could not be reached, or a request timed out.Offer to retry. The session is kept.
AccessDeniedThe person pressed Deny during sign-in.Go back quietly.
StateMismatchThe return page is stale, reused, or from another tab.Start sign-in again.
AbortErrorYou cancelled the request with an AbortSignal.Nothing. This is not a failure.

AbortError is the browser's own DOMException and does not extend UnetIdError.

What an ApiError means

statusTypical codeCauseAction
400invalid_request, invalid_query, invalid_id, content_too_longYour input is wrong.Fix the input; do not retry as is.
403not_allowed, connections_privateA rule applies (blocked, comments off, private connections).Show a message; retrying cannot help.
403forbiddenSomething between you and UNET refused (a proxy or firewall), not UNET.Check your network path.
404not_foundIt does not exist, or you may not see it. UNET does not say which.Treat as missing.
409, 413for example pin_limit_reached, payload_too_largeA limit of the feature.Show the limit.
5xxapi_errorUNET failed.Retry later; GET calls can be retried automatically with retries.

A handler you can copy

import {
  AccessDenied,
  ApiError,
  InsufficientScope,
  NetworkError,
  RateLimited,
  SessionExpired,
  StateMismatch,
  UnetIdError,
} from "@unet/id-web";

export type Outcome =
  | { kind: "ignore" }
  | { kind: "sign-in" } // show the sign-in button
  | { kind: "consent"; scope: string } // ask for a permission
  | { kind: "wait"; seconds: number | null } // rate limited
  | { kind: "retry" } // network or server trouble
  | { kind: "message"; text: string }; // a rule or bad input: tell the person

export function classify(e: unknown): Outcome {
  if (e instanceof DOMException && e.name === "AbortError") return { kind: "ignore" };
  if (e instanceof AccessDenied) return { kind: "ignore" };
  if (e instanceof SessionExpired || e instanceof StateMismatch) return { kind: "sign-in" };
  if (e instanceof InsufficientScope) return { kind: "consent", scope: e.scope };
  if (e instanceof RateLimited) return { kind: "wait", seconds: e.retryAfterSeconds };
  if (e instanceof NetworkError) return { kind: "retry" };
  if (e instanceof ApiError) {
    if (e.status >= 500) return { kind: "retry" };
    if (e.code === "not_allowed") return { kind: "message", text: "That is not allowed." };
    if (e.status === 404) return { kind: "message", text: "That is not available." };
    return { kind: "message", text: e.message };
  }
  if (e instanceof UnetIdError) return { kind: "message", text: e.message };
  throw e; // not ours: a bug in your code should stay loud
}

Save it as classify.ts and use it where you call the API:

import { UnetId } from "@unet/id-web";
import { classify } from "./classify";

declare const unet: UnetId;
declare function showSignIn(): void;
declare function toast(text: string): void;

export async function loadFeed() {
  try {
    return await unet.api.feed();
  } catch (e) {
    const outcome = classify(e);
    switch (outcome.kind) {
      case "sign-in":
        showSignIn();
        break;
      case "consent":
        await unet.signIn({ prompt: "consent" });
        break;
      case "wait":
        toast(`Try again in ${outcome.seconds ?? 30} seconds.`);
        break;
      case "retry":
        toast("Could not reach UNET. Try again.");
        break;
      case "message":
        toast(outcome.text);
        break;
    }
    return null;
  }
}

When to retry

SituationRetry?
NetworkErrorYes, with a pause. Offer the person a retry button for writes.
5xx on a readYes. Set retries on the client to let the SDK do it.
5xx on a writeOnly with the same idempotencyKey (posts), so it cannot post twice.
RateLimitedOnce, after retryAfterSeconds.
400, 403, 404No. The answer will not change.
SessionExpired, InsufficientScopeNot the same call: fix the session or the permission first.

What to show the person

  • Say what happened in their terms ("You can't comment on this post"), not the code.
  • Do not show error.message of an unexpected error as is; log it instead.
  • For a signed-out state, keep what they were doing and bring them back with returnTo.

Common mistakes

  • Checking UnetIdError first. Every error is one; the specific classes never match after it.
  • Retrying a 403 or a 404. It will not change.
  • Logging SessionExpired as an error. It is normal.
  • Swallowing unknown errors. The helper rethrows what it does not recognise; keep that.

What next