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',
  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`)
}

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

Framework-shipped error variants

@voltro/protocol exports exactly two tagged errors. The rest of your typed errors are ones you declare yourself (above) or ones a plugin / the runtime contributes.

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/runtime A tenant-scoped EffectStore write had no authenticated subject.
StoreOperationFailed @voltro/runtime The underlying store operation failed (transient).
TableValidationFailed @voltro/runtime A table().validate(Schema) row check rejected the write.
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 runtime 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.

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', {...}).