Last updated:
Errors flow one way. A handler throws on the server, the runtime maps it to a
transport response, and the client surfaces it as a rejected promise (or as
error on a hook). The one decision you make is which kind of error to throw,
because that decides what the caller sees.
| Throw | Caller sees | Use for |
|---|---|---|
LunoraError(code, message) | the code + message, with the right status | expected/application failures the client handles |
LunoraError with an internal code | the code + status, message stripped | failures the caller should see no detail of |
any other Error (or a raw value) | a generic 500 INTERNAL, message stripped | unexpected bugs never meant to reach the user |
Throwing from a handler
LunoraError is the canonical error type for procedures and middleware. Its
code maps to an HTTP/RPC status automatically, so throwing one is all the
wiring you need.
Import it from lunorash/server (equivalently @lunora/server or @lunora/errors), and put the code first. @lunora/runtime exports a different
LunoraError for the worker entry, with the arguments the other way round — new LunoraError(message, { code, status }). Mixing them up is quiet rather
than loud: new LunoraError("NOT_FOUND", "no such post") against the runtime class mints an error whose message is "NOT_FOUND" and whose code defaults
to INTERNAL, so the caller gets a redacted 500. Inside a query, mutation, action, or middleware you want the (code, message) class below.
import { LunoraError } from "lunorash/server";
import { mutation, query, v } from "@/lunora/_generated/server";
export const send = mutation.input({ channelId: v.id("channels"), text: v.string() }).mutation(async ({ ctx, args }) => {
if (!ctx.auth.userId) {
throw new LunoraError("UNAUTHORIZED", "Sign in to post");
}
const channel = await ctx.db.get(args.channelId);
if (!channel) {
throw new LunoraError("NOT_FOUND", "That channel no longer exists");
}
await ctx.db.insert("messages", { ...args, userId: ctx.auth.userId });
});The codes are a fixed set, each with a status attached:
| Code | Status | Code | Status |
|---|---|---|---|
BAD_REQUEST | 400 | CONFLICT | 409 |
UNAUTHORIZED | 401 | TOO_MANY_REQUESTS | 429 |
FORBIDDEN | 403 | UNPROCESSABLE | 422 |
NOT_FOUND | 404 | NOT_IMPLEMENTED | 501 |
INTERNAL_SERVER_ERROR | 500 | COUNT_RLS_UNSUPPORTED | 422 |
INTERNAL_SERVER_ERROR is an internal code: the client sees the code and
the 500, but its message is replaced with Internal error. Throw it only when
the caller should see no detail.
Expected vs. unexpected
This split is deliberate and security-relevant. The runtime only echoes a
thrown value's code and message to the client when it passes
isLunoraError — an Error instance carrying the type: "VisulimaError"
brand, a string code, and a numeric status. LunoraError sets all
three, and so does the copy an error decodes back into on the far side of the
wire; a hand-rolled class that merely sets name = "LunoraError" and a code
does not, and is redacted like any other throw. Everything else (a plain
throw new Error(...), a TypeError from a bug, a rejected fetch) is logged
server-side and replaced with a generic { code: "INTERNAL", message: "Internal error" }
at status 500. Arbitrary Error.message strings can carry stack traces, file
paths, or internal identifiers, so they never reach the wire.
Codes the catalog marks internal are redacted too, even when thrown as a
LunoraError: the code and status reach the client, but the message is
replaced with Internal error. See the
errors reference for the list.
The practical rule: throw LunoraError for anything the client should be able
to react to (not-found, forbidden, validation, rate-limited). Let unexpected
failures throw naturally, and the runtime will hide their detail for you.
Two failures you don't throw yourself:
- Input validation. Args are checked against your
v.*schema before the handler runs; a mismatch surfaces as a400BAD_REQUESTwithout you writing any code. - Write conflicts. When two mutations race the same row, the optimistic-
concurrency layer rejects the loser with code
CONFLICT(409). The client exposesisConflictError(error)so you can refetch-and-retry instead of treating it as a hard failure.
Reading error state on the client
A mutation call rejects with a normalized Error carrying the server's code,
so a try/catch is enough for one-off calls:
import { isConflictError } from "lunorash/client";
try {
await send({ channelId, text });
} catch (error) {
if (isConflictError(error)) {
// lost a write race — refetch and retry
} else {
// error.message is the LunoraError message, or "Internal error"
}
}For rendering, the React hooks expose error state directly so you don't manage a
separate useState.
useMutation returns { mutate, pending, ... }, not a callable: destructure
mutate to invoke the mutation and pending for in-flight UI. Await mutate
and catch to handle failure:
import { useMutation } from "@lunora/react";
import { api } from "@/lunora/_generated/api";
const { mutate: send, pending } = useMutation(api.messages.send);
// pending is true while a call is in flight
await send({ channelId, text });useSubscription is the lower-level read hook and surfaces { data, error },
so you render an error branch inline:
import { useSubscription } from "@lunora/react";
import { api } from "@/lunora/_generated/api";
const { data, error } = useSubscription(api.messages.list, { channelId });
if (error) return <Banner>{error.message}</Banner>;
return <Messages rows={data ?? []} />;Optimistic updates roll back on rejection
When a mutation carries an optimistic update, the client writes the optimistic
value onto every subscribed query it touches and records a rollback closure for
each. If the mutation rejects (a thrown LunoraError, a CONFLICT, a network
failure), those closures fire and the cache reverts. You don't write any
rollback code; a rejected mutation leaves the UI as it was.
const { mutate: send } = useMutation(api.messages.send);
await send({ channelId, text }, { optimistic: (current) => [...(current ?? []), draft] });
// If this rejects, `draft` is removed from the list automatically.The rollback is conservative: it only restores when no newer server delta has superseded the optimistic write and no later optimistic write has stacked on top of it, so concurrent activity never gets clobbered by a late-arriving rollback.
See also
- Queries & mutations: the three function kinds and
ctx. - Constraints: declarative checks that fail closed.
- Offline-first: how queued mutations retry and surface failures.
- @lunora/react:
useMutation/useSubscription/.pending. - @lunora/client:
isConflictErrorand the rejection envelope.