Error handling

Schema-tagged error variants, runtime errors vs business errors, client narrowing, retry semantics.

Voltro distinguishes business errors (typed, declared in the schema, expected) from runtime errors (unexpected exceptions, transport failures, framework bugs). Each surface has clear semantics.

Live — demo.greet throws a typed NameTooLong for a name over 20 chars; the client narrows on _tag and reads the typed fields:

const greet = useAction('app', 'demo.greet')
try { await greet.run({ name }) }
catch (e) { if (e._tag === 'NameTooLong') { /* typed: e.max, e.actual */ } }

Business errors

Declare your own with Schema.TaggedError — the class-extension form:

import { Schema } from 'effect'

class InsufficientFunds extends Schema.TaggedError<InsufficientFunds>()('InsufficientFunds', {
  required:  Schema.Number,
  available: Schema.Number,
}) {}

class TitleTooLong extends Schema.TaggedError<TitleTooLong>()('TitleTooLong', {
  maxLength: Schema.Number,
}) {}

Wire them into a mutation / action / query via the error field:

export const transfer = defineMutation({
  name:   'wallet.transfer',
  guards: [{ scope: 'wallet:transfer' }],
  input:  Schema.Struct({ to: Schema.String, amount: Schema.Number }),
  output: Schema.Struct({ txId: Schema.String }),
  error:  Schema.Union(InsufficientFunds, TitleTooLong),
})

export default async (input, ctx) => {
  const wallet = await ctx.store.select('wallets').where('id', ctx.request.subject.id).one()
  if (wallet.balance < input.amount) {
    throw new InsufficientFunds({ required: input.amount, available: wallet.balance })
  }
  // …
}

Client narrows on _tag:

const transfer = useMutation('app', 'wallet.transfer')

const result = await transfer.mutate({ to: 'usr-42', amount: 100 }).catch((e) => e)

if (result._tag === 'InsufficientFunds') {
  // result.required, result.available are typed
  toast.error(`Need ${result.required}, have ${result.available}`)
} else if (result._tag === 'TitleTooLong') {
  toast.error(`Too long — max ${result.maxLength}`)
} else if ('txId' in result) {
  toast.success(`Transfer ${result.txId} complete`)
}

What a rejected write actually rejects with

The value is the typed error itself — the same value useSubscription reports on the read side. Not a wrapper around it, and not an Effect FiberFailure (whose _tag would be undefined, so every branch above would silently fall through to the generic one).

That guarantee covers every write hook, not just mutate:

Hook Rejecting call
useMutation mutate(input)
useAction run(input)
useWorkflow start · cancel · resume · signal · update
useWorkflowSignal / useWorkflowUpdate signal · update
useUpload upload · uploadMany

Two consequences worth knowing:

  • A defect (an undeclared throw server-side, a transport drop) rejects too, but with a plain Error — so an _tag check on it is undefined and falls to your generic branch, which is the intended split. Check _tag for the outcomes you declared; treat everything else as unexpected.
  • Passing onError (or notify.error) instead resolves with undefined and hands that same typed value to your handler. See Mutations.

Tagged errors:

  • Are wire-safe — they serialise as JSON (the rpc transport is RpcSerialization.layerJson) and restore on the client with the correct _tag + payload.
  • Narrow correctly in TypeScript via the _tag discriminant.
  • Carry typed payload fields.
  • Don't fire alerts / unhandled-promise-rejection signals — they're expected business outcomes.

Runtime errors

Unexpected throws — TypeError, RangeError, framework bugs, third-party SDK failures — don't match the descriptor's error union, so they surface as defects rather than typed failures:

  • The throw is logged (via @voltro/logger to your configured sink).
  • The client sees a generic failure (no typed payload — runtime errors might leak sensitive context).
  • The trace records the throw + trace ID for cross-referencing; voltro logs --trace <id> returns the whole causal chain.
  • The trace is searchable in OpenTelemetry.

To distinguish "we know about this" from "this surprised us":

Throw Treatment
throw new InsufficientFunds({ … }) (declared tagged error in error:) Marshalled to the client typed, no log-volume increase
throw new Error('oops') Surfaces as a defect — full log + trace, generic failure to client
Unexpected exception from a library Same — caught, logged, generic failure

What the framework merges into your error: union

You do not declare the errors the framework itself can raise for a procedure — they are unioned into the wire contract for you, and only when the procedure can actually produce them:

Merged Into When
ScopeError query · mutation · action · event the procedure declares a real guards: entry. NOT for openAccess: — a procedure advertising a denial it cannot produce is what makes an error union stop meaning anything
Unauthenticated query · mutation · action · event the same condition. A guard can refuse for TWO reasons and they mean different things: the caller is known and lacks the scope (ScopeError), or they presented a credential that was REJECTED and so arrived anonymous (Unauthenticated). Without the second, an expired session reads as a permissions problem
BusinessRuleViolation mutation always. A cross-table rule() on any table the mutation writes can fail it, and the descriptor cannot know which tables carry rules
ApprovalRequired · ApprovalExpired · ApprovalUnavailable mutation · action the procedure declares requiresApproval:

So a guarded mutation does not need ScopeError in its own error:. If you declared it anyway, that is harmless — the union is the same either way.

Framework-shipped error variants

@voltro/protocol exports the framework's own tagged errors. The rest of your typed errors are ones you declare yourself (above) or ones a plugin contributes.

Import them from @voltro/protocol, not from @voltro/runtime — even the store errors the runtime raises. A descriptor that declares one in error: is loaded value-level by the web client (the RpcClient needs every procedure's Schema), and @voltro/runtime is server-only, so a descriptor importing from it is refused at boot by the browser-safety guard. @voltro/runtime re-exports them for server code, which never sees the difference.

Variant From When
ScopeError @voltro/protocol requireScope(subject, scope) failed — { required, message }.
Unauthenticated @voltro/protocol The resolved Subject is anonymous but a signed-in caller was required — optional { reason }.
TenantScopeViolation @voltro/protocol A tenant-scoped EffectStore write had no authenticated subject.
TenantRowNotFound @voltro/protocol A keyed-by-id write (store.update(t, id, …), delete, hardDelete, patchJson) on a tenant() table found no such row in the caller's tenant. Raised identically whether the row is missing or belongs to another tenant — the distinction would be a cross-tenant existence oracle.
ServerOnlyColumnWrite @voltro/protocol A crud.create / crud.update input tried to set a .serverOnly() column — { table, columns }.
StoreOperationFailed @voltro/protocol The underlying store operation failed (transient).
TableValidationFailed @voltro/protocol A table().validate(Schema) row check rejected the write.
ConstraintViolation @voltro/protocol The database refused the write on a foreign key / unique / NOT NULL / CHECK — { kind, table, operation, constraint?, column? }. See below.
CacheError @voltro/cache A cache backend op failed — { operation, key, cause }.
RateLimited @voltro/plugin-ratelimit The limiter rejected the call — { limit, retryAfterMs, resetAtMs }.
TenantMismatch @voltro/plugin-multitenancy assertOwnTenant(input.tenantId, subject) rejected a cross-tenant write.

Each plugin that ships an error (RateLimited, EntitlementExceeded, StorageError, MailError, …) merges it into every procedure's wire-error union, so the client decodes it typed without you adding it to each error:. To surface a store error to the client, add it to the descriptor's error: union yourself (e.g. error: Schema.Union(TenantScopeViolation, StoreOperationFailed, MyDomainError)).

All carry _tag + typed payloads, all narrow correctly on the client.

When the database refuses the write

An integrity rule your schema declares — a reference(), a .unique(), a non-nullable column, a .check() — is enforced by the database, not by the handler. When it fires you get a ConstraintViolation:

{ _tag: 'ConstraintViolation',
  kind: 'foreignKey', table: 'tasks', operation: 'insert',
  constraint: 'tasks_laneId_fkey' }

kind is the field to branch on, and it is the one field every dialect can always fill:

kind Means The caller's fix
foreignKey the row you referenced does not exist point at a real row
foreignKeyInUse this row may not go — others still reference it delete the children first, or don't delete
unique a row with this value already exists pick another value
notNull the column requires a value send one
check the row does not satisfy a declared .check() fix the value

constraint and column are filled where the dialect names them. sqlite reports a foreign-key failure as the bare sentence FOREIGN KEY constraint failed — no name, no column — so constraint is absent there and the direction is inferred from the operation.

It carries names, never the driver's sentence, and that is deliberate. The message the database produces contains row DATA on most engines: postgres attaches Failing row contains (…) — the complete row, every column, .sensitive() ones included — to a not-null and a check violation; mysql and mssql echo the duplicate value on a unique violation. A constraint name is schema, the same class of fact TableValidationFailed.table already puts on the wire. A row is data, and the caller who provoked the error is not automatically entitled to it. The full driver text is in the server log, with the trace id.

Declare it in error: to pattern-match it:

error: Schema.Union(ConstraintViolation, MyDomainError),

If you do not, it still reaches the client — collapsed to InternalError like any undeclared error, but carrying its own sentence (ConstraintViolation: foreign key tasks_laneId_fkey on tasks: the referenced row does not exist) rather than the opaque Failed to execute statement a raw SqlError produces.

When the FRAMEWORK refuses the write

A few refusals are ours rather than the database's. The MySQL-family upsert has one: ON DUPLICATE KEY UPDATE fires on whichever unique key the incoming row violates, which may not be the one your conflictColumns named, so the store checks afterwards and rolls back when the statement reached a row those columns could not have selected. The database was perfectly happy — we are the ones saying no.

These arrive as ConstraintViolation too, on the same failure channel and with the same kind, so nothing downstream needs a second branch. They were plain throws until 0.58.0, which made them defects: the client received a Defect wrapping a stringified InternalError, with no tag to match on, for a condition an app can genuinely branch on.

They carry one extra field:

{ _tag: 'ConstraintViolation',
  kind: 'unique', table: 'projectHours', operation: 'upsert',
  detail: 'MysqlStore.upsert: the row written to …' }

detail is a sentence the framework authored, and it replaces the generated one in message. It is populated only for these refusals — never from a driver message, and never with a row value. That is the same rule the generated sentence follows: on several dialects the driver's own text contains row data, so it is not forwarded. A sentence we wrote carries exactly what we put in it.

Loader errors

Loaders run in the web app — they receive { params, query, headers, signal }, NOT a server ctx with .store. A loader reaches the backend through query(...) (the POST /rpc path, server-side only). It short-circuits with the branded control-flow signals NotFoundError / RedirectError:

import { notFound } from '@voltro/web'

export const loader = async ({ params, query }) => {
  // `query` is present only server-side (SSR/ISR). Guard for client nav.
  const note = query ? await query('notes.get', { id: params.id }) : undefined
  if (!note) throw notFound(`note ${params.id}`)   // → scoped not-found.tsx / 404
  return note
}

A NotFoundError renders the scoped not-found.tsx subtree (it is NOT routed to the error.tsx boundary). A plain throw new Error(...) DOES hit error.tsx:

export default function ErrorPage({ error, reset }: { error: Error; reset: () => void }) {
  return <GenericError error={error} reset={reset} />
}

See Loaders & meta for NotFoundError / RedirectError and the ctx.query SSR path.

Retry semantics

Surface Auto-retry? Notes
useSubscription ✓ on disconnect The api connection auto-reconnects with exponential backoff (500ms × 2^(attempt-1), capped at 5s) and re-subscribes; the server replays the current snapshot. Reconnect attempts are not capped — it keeps trying until the connection is restored or the component unmounts.
useMutation Mutations might be non-idempotent. Retry only when the operation is safe to repeat.
useAction Same.
useAgentStream / useAgent Streaming is hard to resume. Use a workflow for durable agent runs.
Workflow steps Configurable per-workflow + per-step.

For mutations that are safe to repeat, wrap mutate in your own retry:

const result = await retry(
  () => create.mutate(input),
  { attempts: 3, backoff: 'expo' },
)

For operations that must be durable or exactly-once across disconnects, queue the work through a workflow and use database uniqueness constraints around the business key.

Validation errors

Each rpc decodes its input against the declared input schema before the executor runs. A payload that fails the schema is rejected by @effect/rpc as a decode failure — the executor never runs. To surface field-level messages to a form UI, declare your own validation error and decode the input yourself in the handler:

import { Schema } from 'effect'

class ValidationFailed extends Schema.TaggedError<ValidationFailed>()('ValidationFailed', {
  errors: Schema.Array(Schema.Struct({ path: Schema.String, message: Schema.String })),
}) {}

You can attach custom messages to the field constraints with Schema.message:

Schema.String.pipe(
  Schema.minLength(1, { message: () => 'Title is required' }),
  Schema.maxLength(200, { message: () => 'Title is too long (max 200 chars)' }),
)

Schema-level row validation on a table (table().validate(Schema)) throws the runtime's TableValidationFailed — declare it in the mutation's error: to surface it typed.

Network errors

A WebSocket disconnect interrupts in-flight unary calls — the awaited mutate / run rejects. The framework reconnects query subscriptions automatically; for mutations and actions, you decide whether retrying is safe:

try {
  await create.mutate(input)
} catch (e) {
  // Connection dropped mid-call. Wait for the api to reconnect, then retry
  // ONLY if the operation is idempotent.
  await waitForReconnect()
  await create.mutate(input)
}

Anti-patterns

  • Swallowing every error and showing "Something went wrong". You're hiding genuine bugs. Let the dashboard's error pane + voltro traces --errors surface them; only catch the specific tagged variants you've declared.
  • Throwing strings. throw 'oh no' → surfaces as a defect, no useful payload. Use Error subclasses or Schema.TaggedError variants.
  • if (error instanceof InsufficientFunds) on the client. The wire-deserialised value isn't structurally identical to the server's class. Always check _tag.
  • The curried Schema.TaggedError('Name')({...}) form. That doesn't type-check — use the class-extension form class X extends Schema.TaggedError<X>()('X', {...}).