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
_tagdiscriminant. - 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/loggerto 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 --errorssurface them; only catch the specific tagged variants you've declared. - Throwing strings.
throw 'oh no'→ surfaces as a defect, no useful payload. UseErrorsubclasses orSchema.TaggedErrorvariants. 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 formclass X extends Schema.TaggedError<X>()('X', {...}).