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.
| Error | Means | What the app does |
|---|---|---|
SessionExpired | The 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. |
InsufficientScope | The 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. |
RateLimited | Too many requests (retryAfterSeconds may be null). | Wait, then try once. Tell the person if it takes long. |
ApiError | UNET refused or failed: status and code say how. | Depends on code; see below. |
NetworkError | UNET could not be reached, or a request timed out. | Offer to retry. The session is kept. |
AccessDenied | The person pressed Deny during sign-in. | Go back quietly. |
StateMismatch | The return page is stale, reused, or from another tab. | Start sign-in again. |
AbortError | You 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
status | Typical code | Cause | Action |
|---|---|---|---|
| 400 | invalid_request, invalid_query, invalid_id, content_too_long | Your input is wrong. | Fix the input; do not retry as is. |
| 403 | not_allowed, connections_private | A rule applies (blocked, comments off, private connections). | Show a message; retrying cannot help. |
| 403 | forbidden | Something between you and UNET refused (a proxy or firewall), not UNET. | Check your network path. |
| 404 | not_found | It does not exist, or you may not see it. UNET does not say which. | Treat as missing. |
| 409, 413 | for example pin_limit_reached, payload_too_large | A limit of the feature. | Show the limit. |
| 5xx | api_error | UNET 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
| Situation | Retry? |
|---|---|
NetworkError | Yes, with a pause. Offer the person a retry button for writes. |
| 5xx on a read | Yes. Set retries on the client to let the SDK do it. |
| 5xx on a write | Only with the same idempotencyKey (posts), so it cannot post twice. |
RateLimited | Once, after retryAfterSeconds. |
| 400, 403, 404 | No. The answer will not change. |
SessionExpired, InsufficientScope | Not 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.messageof 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
UnetIdErrorfirst. Every error is one; the specific classes never match after it. - Retrying a 403 or a 404. It will not change.
- Logging
SessionExpiredas an error. It is normal. - Swallowing unknown errors. The helper rethrows what it does not recognise; keep that.