Skip to content
DocsDocumentation

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, on 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.