Errors & warnings reference

Every well-known Lunora error code — its HTTP status, what it means, and what to do about it.

Last updated:

Every error Lunora throws carries a machine-readable code. The code keys into a central catalog that supplies the transport status, a human title, and — for the codes where it helps — an actionable hint. The same catalog drives the CLI renderer, the Vite error overlay, the Studio, and the client SDK, so an error reads the same wherever you meet it.

import { isLunoraError } from "@lunora/errors";

if (isLunoraError(error)) {
    error.code; //   "CONFLICT"
    error.status; // 409
    error.hint; //   actionable Markdown, when the catalog has one
}

isLunoraError matches structurally — a string code plus a numeric status — so it also recognises errors rebuilt from the wire, where instanceof cannot be trusted.

Request errors

CodeStatusMeaning
BAD_REQUEST400The request was malformed
VALIDATION_ERROR400Arguments or a return value failed validation
UNAUTHORIZED401No usable identity on the request
FORBIDDEN403Authenticated, but not allowed
NOT_FOUND404The addressed resource does not exist
UNPROCESSABLE422Well-formed, but semantically rejected
TOO_MANY_REQUESTS429Rate limited — check Retry-After
NOT_IMPLEMENTED501The operation exists but is not implemented

Client helpers exist for the ones you branch on most: isUnauthorizedError, isForbiddenError, isRateLimitedError (plus getRetryAfterMs), and isConflictError, all from @lunora/client.

Data errors

CodeStatusMeaning
CONFLICT409A concurrent write changed the row mid-mutation
NOT_UNIQUE400.unique() matched more than one document
UNKNOWN_TABLE404No such table in the schema
GLOBAL_TABLE_NOT_EDITABLE400A .global() table cannot be edited through this path
MIGRATION_NOT_FOUND404No data migration with that id

CONFLICT

Another write changed the row while your mutation was running. Re-read the row and retry with the fresh value.

Because Lunora serializes a DO's mutations, a persistent conflict usually means the handler conflicts with itself — a trigger or cascade touching the same row. Split that work rather than adding a retry loop. See OCC & atomicity.

A unique-index breach on the write path also surfaces as CONFLICT, with the message unique constraint violation on <table>. That one is different: either upsert (or patch the existing row) instead of inserting, or pick a value that is not taken.

NOT_UNIQUE

.unique() expects the query to identify at most one row. If several matches are legitimate, use .first() or .collect() instead; otherwise tighten the query so it can only match one row.

Authorization and privacy errors

CodeStatusMeaning
RLS_REQUIRED403The table is secure-by-default and no policy resolved
COUNT_RLS_UNSUPPORTED422count() is unsupported under an RLS policy
MASK_UNSUPPORTED422Aggregation over a masked column is unsupported
RELATION_PREDICATE_UNSUPPORTED422A relation predicate is unsupported in a write policy

RLS_REQUIRED

The table has no .public() marker and no RLS policy resolved for this caller, so the read fails closed. Add a read policy with .rls(...), or mark the table .public() if it is genuinely world-readable.

Dispatch errors

CodeStatusMeaning
FUNCTION_NOT_FOUND404No function at that path
METHOD_NOT_ALLOWED405Wrong HTTP method for this endpoint
PAYLOAD_TOO_LARGE413The request body exceeded the limit

Infrastructure errors

CodeStatusMeaning
SHARD_ERROR503The owning shard returned an error
SHARD_UNAVAILABLE503The owning shard could not be reached
OFFLINE_IDENTITY_CHANGED409The identity changed while offline writes queued
ANALYTICS_SQL_ERROR502Analytics Engine SQL API failure
R2_SQL_ERROR502R2 SQL API failure
WORKFLOWS_REST_ERROR502Cloudflare Workflows REST API failure

The three *_ERROR codes carry Cloudflare's own error text — trusted infrastructure rather than user input — so the upstream message is echoed rather than redacted, and the actual upstream HTTP status is passed through.

Auth and email errors

CodeStatusMeaning
EMAIL_DOMAIN_BLOCKED400The address's domain is disposable or on your deny-list
EMAIL_UNDELIVERABLE400The domain publishes no MX records, so mail cannot arrive
AUTH_HEADERS_MISSING500Auth is not wired up correctly (redacted on the wire)

Tune the domain policy with blockDisposable / allowDomains / denyDomains on emailGate(...) in @lunora/auth/email-guard. MX verification is opt-in (mx: true) and needs DNS, so leave it off on an edge path where DNS is not available.

Internal errors

These are redacted on the wire: their messages may carry SQL fragments, file paths, or internal identifiers, so the transport emits a generic message and logs the real one server-side.

CodeStatusMeaning
INTERNAL500Free-form internal failure
INTERNAL_SERVER_ERROR500Alias of INTERNAL
RPC_FAILED500A non-mappable throw crossed the RPC boundary
RUN_DEPTH_EXCEEDED500Too many nested function invocations
ENV_INVALID500Environment validation failed — names failing keys

Throwing a LunoraError with any non-internal code is your vouch that its message is safe to show a client. invariant(...) and unreachable(...) throw INTERNAL, so "should never happen" assertions are redacted automatically.

Build-time errors

These never cross the RPC wire.

CodeMeaning
CODEGEN_DIAGNOSTICCodegen rejected your source
SCHEMA_SNAPSHOT_PARSEThe migration snapshot is corrupt

Codegen errors are thrown as plain messages rather than codes, so Lunora matches them by message text and attaches a solution. The ones with dedicated guidance:

SituationWhat to do
No Lunora schema foundCreate lunora/schema.ts with a defineSchema default export, or run lunora init
defineSchema() needs an inline object literalPass the object literal directly — codegen reads it statically and cannot follow a variable or spread
Table name is reservedThe name collides with a built-in ctx.db member; rename the table
Duplicate table nameTwo tables resolve to the same name, usually a base table and a .extend(...) both defining it
Invalid .jurisdiction(...) valueOnly the string literals "eu", "us", or "fedramp" are accepted
unique must be a literalWrite { unique: true }, not a computed value
Binding not exported by your worker entryAdd the generated re-export (e.g. export * from "./lunora/_generated/containers") to your worker entry

Throwing your own

LunoraError takes a code and a message, and fills in status, title, and hint from the catalog:

import { LunoraError } from "@lunora/errors";

throw new LunoraError("NOT_FOUND", `no message with id ${id}`);

Explicit options override the catalog defaults. For errors you expect the client to branch on, see Error handling.

See also