Skip to content
DocsconceptsDocumentation

Error handling

Throwing inside handlers, how errors travel to the client, and reading error state without losing optimistic safety.

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.

ThrowCaller seesUse for
LunoraError(code, message)the code + message, with the right statusexpected/application failures the client handles
LunoraError with an internal codethe code + status, message strippedfailures the caller should see no detail of
any other Error (or a raw value)a generic 500 INTERNAL, message strippedunexpected 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:

CodeStatusCodeStatus
BAD_REQUEST400CONFLICT409
UNAUTHORIZED401TOO_MANY_REQUESTS429
FORBIDDEN403UNPROCESSABLE422
NOT_FOUND404NOT_IMPLEMENTED501
INTERNAL_SERVER_ERROR500COUNT_RLS_UNSUPPORTED422

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 a 400 BAD_REQUEST without 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 exposes isConflictError(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