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
throwserver-side, a transport drop) rejects too, but with a plainError— so an_tagcheck on it isundefinedand falls to your generic branch, which is the intended split. Check_tagfor the outcomes you declared; treat everything else as unexpected. - Passing
onError(ornotify.error) instead resolves withundefinedand 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
_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 |
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 --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', {...}).