Skip to content
IC Reactor

Error Handling

IC Reactor provides structured error handling for both canister-level and network-level errors. This guide covers everything you need to know about handling errors in your application.

IC Reactor distinguishes between three types of errors:

CanisterError

Business logic errors from your canister (e.g., InsufficientFunds, NotFound, Unauthorized)

CallError

Network, agent, or trap errors (e.g., connection timeout, canister trapped)

ValidationError

Argument validation failures raised by DisplayReactor before the call is ever sent

Thrown when your canister returns an Err variant from a Result type:

import { CanisterError } from "@ic-reactor/react"
// Your canister method returns Result<User, GetUserError>
// On Err, IC Reactor throws CanisterError with the error value
function logError(error: unknown) {
if (error instanceof CanisterError) {
console.log("Canister error:", error.err)
console.log("Error code:", error.code) // Extracted from variant key
// error.err contains the Err variant. From a Reactor, e.g.:
// { NotFound: null }
// { Unauthorized: { reason: "Not owner" } }
// { InsufficientFunds: { balance: 100n, required: 500n } }
// From a DisplayReactor, a variant carries its case in `_type`:
// { _type: "NotFound" }
// { _type: "Unauthorized", Unauthorized: { reason: "Not owner" } }
// { _type: "InsufficientFunds", InsufficientFunds: { balance: "100", required: "500" } }
}
}

An error record with a text code is read as an API error, such as Orbit’s record { code : text; message : opt text; details : opt vec record { text; text } }. Its code is error.code, its details field is error.details as decoded, and its message is error.message: the text, or the text inside the opt text, so a Reactor and a DisplayReactor give the same message. With no message, error.message is the record written as JSON.

CanisterError.isApiError(value) checks only that code, message and details are present. It narrows to ApiError, whose message and details are unknown unless you name them (ApiError<Message, Details>), so check their shape before using them.

Thrown for network, agent, or canister trap errors:

import { CallError } from "@ic-reactor/react"
function logError(error: unknown) {
if (error instanceof CallError) {
console.log("Call error:", error.message)
// Network timeout, canister trapped, etc.
}
}

Thrown by a DisplayReactor when the arguments you pass fail its validators — before anything is sent to the canister. It carries the failing method name and one issue per invalid field:

import { createMutation, isValidationError } from "@ic-reactor/react"
import { backend } from "../reactor"
const transferMutation = createMutation(backend, { functionName: "transfer" })
async function submitTransfer() {
try {
await transferMutation.execute([{ to: "", amount: "-100" }])
} catch (error) {
if (isValidationError(error)) {
console.log(error.methodName) // "transfer"
console.log(error.issues)
// [
// { path: ["to"], message: "Recipient is required" },
// { path: ["amount"], message: "Amount must be positive" }
// ]
}
}
}

A validator that throws or rejects, rather than returning its verdict, is not a ValidationError: callMethod(), callMethodWithValidation() and validate() all report it as a CallError whose cause is what the validator threw, and nothing is sent to the canister.

@ic-reactor/react also ships form helpers built on it — mapValidationErrors, getFieldError, getFieldErrors, extractValidationErrors, and handleValidationError. See Validation for the full surface.

import { CanisterError, CallError } from "@ic-reactor/react"
import { useActorQuery } from "../reactor/hooks"
function UserProfile({ userId }: { userId: string }) {
const { data, error, isError, isPending, refetch } = useActorQuery({
functionName: "get_user",
args: [userId],
})
if (isPending) {
return <LoadingSpinner />
}
if (isError) {
if (error instanceof CanisterError) {
// Handle specific business logic errors. A DisplayReactor reports a
// variant with its case in `_type`, and a case with no value, such as
// NotFound, has no key of its own: `"NotFound" in error.err` is false.
if (error.err._type === "NotFound") {
return <UserNotFound userId={userId} />
}
if (error.err._type === "Unauthorized") {
return <Unauthorized message={error.err.Unauthorized.reason} />
}
return (
<div>
Error: {error.code} - {JSON.stringify(error.err)}
</div>
)
}
// Network or other errors
return (
<div className="error">
<p>Failed to load user: {error.message}</p>
<button onClick={() => refetch()}>Retry</button>
</div>
)
}
return <UserCard user={data} />
}

Use React Error Boundaries for unexpected errors. The react-error-boundary package provides a convenient ErrorBoundary component:

Terminal window
pnpm add react-error-boundary
import { ErrorBoundary, type FallbackProps } from "react-error-boundary"
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div className="error-fallback">
<h2>Something went wrong</h2>
{/* A boundary catches whatever was thrown, so `error` is not always an Error */}
<pre>{error instanceof Error ? error.message : String(error)}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)
}
function App() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<YourApp />
</ErrorBoundary>
)
}

onCanisterError fires only for Result { Err } variants from the canister. This keeps canister business-logic errors separate from network and agent failures:

import { formatTokenAmount } from "@ic-reactor/react"
import { useActorMutation } from "../reactor/hooks"
/** This token's decimals, as `icrc1_decimals` returns them. */
const DECIMALS = 8
function TransferForm() {
const { mutate, isPending } = useActorMutation({
functionName: "transfer",
// Only fires for canister Result { Err } variants
onCanisterError: (error) => {
if (error.err._type === "InsufficientFunds") {
// A DisplayReactor gives the amounts as text in base units
const { balance, required } = error.err.InsufficientFunds
const have = formatTokenAmount(balance, DECIMALS)
const need = formatTokenAmount(required, DECIMALS)
toast.error(`Insufficient funds. Have: ${have}, Need: ${need}`)
} else if (error.err._type === "InvalidRecipient") {
toast.error("Invalid recipient address")
} else {
toast.error("Transaction failed: " + error.code)
}
},
// Fires for ALL errors — use for logging/monitoring
onError: (error) => {
console.error("Transfer error", error)
},
})
// ...
}

onError fires for every error type. Use it when you want a single handler or when you need to distinguish manually:

import { CanisterError, formatTokenAmount } from "@ic-reactor/react"
import { useActorMutation } from "../reactor/hooks"
/** This token's decimals, as `icrc1_decimals` returns them. */
const DECIMALS = 8
function TransferForm() {
const { mutate, isPending, error } = useActorMutation({
functionName: "transfer",
onError: (error) => {
if (error instanceof CanisterError) {
// Handle specific error variants
if (error.err._type === "InsufficientFunds") {
const { balance, required } = error.err.InsufficientFunds
const have = formatTokenAmount(balance, DECIMALS)
const need = formatTokenAmount(required, DECIMALS)
toast.error(`Insufficient funds. Have: ${have}, Need: ${need}`)
return
}
if (error.err._type === "InvalidRecipient") {
toast.error("Invalid recipient address")
return
}
}
// Generic error handling
toast.error("Transfer failed: " + error.message)
},
})
// ...
}
import { CanisterError } from "@ic-reactor/react"
import { useActorMutation } from "../reactor/hooks"
function TransferButton() {
const { mutateAsync } = useActorMutation({
functionName: "transfer",
})
const handleTransfer = async () => {
try {
const result = await mutateAsync([{ to: recipient, amount }])
toast.success("Transfer successful!")
} catch (error) {
if (error instanceof CanisterError) {
handleCanisterError(error)
} else {
toast.error("Network error. Please try again.")
}
}
}
return <button onClick={handleTransfer}>Transfer</button>
}

For type-safe error handling, read the error type off the reactor instead of copying it from your Candid file. ReactorErrorOf gives what a call of a method rejects with, in the reactor’s own form: a DisplayReactor reports each variant case in _type, and a Reactor reports the raw Candid variant.

import {
formatTokenAmount,
isCanisterError,
type ReactorErrorOf,
} from "@ic-reactor/react"
import { backend } from "../reactor"
import { useActorMutation } from "../reactor/hooks"
/** This token's decimals, as `icrc1_decimals` returns them. */
const DECIMALS = 8
// CanisterError<TransferError in display form> | CallError | ValidationError
type TransferError = ReactorErrorOf<typeof backend, "transfer">
function handleTransferError(error: TransferError): string {
if (isCanisterError(error)) {
switch (error.err._type) {
case "InsufficientFunds": {
const balance = formatTokenAmount(
error.err.InsufficientFunds.balance,
DECIMALS
)
return `Insufficient balance: ${balance}`
}
case "InvalidRecipient":
return "Invalid recipient address"
case "AmountTooSmall": {
const minimum = formatTokenAmount(
error.err.AmountTooSmall.minimum,
DECIMALS
)
return `Minimum amount: ${minimum}`
}
case "Unauthorized":
return "You are not authorized to perform this action"
}
}
return error.message
}
// Usage: a mutation's `error` is already a TransferError
function useTransfer() {
return useActorMutation({
functionName: "transfer",
onError: (error) => toast.error(handleTransferError(error)),
})
}

Most canister-call failures cannot change on retry: a canister Err variant, a ValidationError, a Candid encode failure that never produced a request, a decode failure on the reply (the same reply decodes to the same error — and for an update, the canister may already have committed the change, so a retry would submit it again), a replica rejection the replica will simply repeat for an identical call, an HTTP 4xx refusal of the request itself (an expired or invalid delegation, a bad signature, a malformed request). Retrying those costs several attempts and seconds of backoff to arrive at the same failure. Only a CallError caused by an agent-level fault — a transport, protocol, or certificate failure, an HTTP 5xx, 408 or 429 answer, or a SysTransient/SysUnknown rejection — can plausibly produce a different result next time.

IC Reactor ships this classification as two functions, exported from @ic-reactor/core and re-exported by @ic-reactor/react:

  • isRetryableReactorError(error) — returns true only when a retry could help. CanisterError, ValidationError, and a CallError without an agent error underneath (encode/decode/transform failures) are never retryable, and neither is an HTTP 4xx answer other than 408 and 429. The agent has already sent such a request retryTimes more times (3 by default) before it reports the error, so a session whose delegation has lapsed fails after those 4 requests instead of 16 and about 20 seconds of backoff. Within agent errors the bias runs the other way: an unrecognized kind, or a rejection whose code cannot be read, still retries, so an unfamiliar transport-level fault is never silently made fatal.
  • reactorRetry(failureCount, error) — a TanStack Query retry predicate built on it: up to 3 retries (4 attempts in total) for retryable errors, none otherwise. It always returns false on the server (typeof window === "undefined"), preserving TanStack Query’s no-retries-on-server default so a failed prefetch or render keeps failing fast.

The QueryClient that defineReactor creates already uses reactorRetry as its default for queries. A QueryClient you construct yourself opts in explicitly:

import { QueryClient } from "@tanstack/react-query"
import { reactorRetry } from "@ic-reactor/react"
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: reactorRetry,
},
},
})

A retry of an update call is a second call: every attempt goes out under a new request id, so the canister runs each one it receives. A retry is safe only when the failure proves the canister never ran the first attempt, and only a SysTransient rejection does. A transport failure can come after the replica accepted the call, and a SysUnknown rejection says its outcome is unknown, so isRetryableReactorError — right for query methods — would let a lost response execute an update twice. Two more functions cover update calls:

  • isRetryableUpdateError(error) — true only for a CallError caused by a SysTransient rejection.
  • reactorUpdateRetry(failureCount, error) — up to 3 retries of such a failure, none of anything else, and none on the server.

The query hooks and factories apply this rule to an update method whose query sets no retry of its own, within what the QueryClient’s retry default allows; see Update Methods in Queries. Mutations retry nothing by default. To retry one only when that cannot run it twice:

import { createMutation, reactorUpdateRetry } from "@ic-reactor/react"
import { ledger } from "../reactor/ledger"
const transfer = createMutation(ledger, {
functionName: "icrc1_transfer",
retry: reactorUpdateRetry,
})

A mutations.retry in the QueryClient’s defaults applies to every mutation: useActorMutation, useActorMethod’s update calls and createMutation, through useMutation() and execute() alike. A plain number there re-sends state-changing calls after any failure; reactorUpdateRetry is the safe choice for such a default.

Combine isRetryableReactorError with your own policy when you need different attempt counts or delays:

import { QueryClient } from "@tanstack/react-query"
import { isRetryableReactorError } from "@ic-reactor/react"
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) =>
failureCount < 5 && isRetryableReactorError(error),
retryDelay: (attemptIndex) => {
// Exponential backoff: 1s, 2s, 4s...
return Math.min(1000 * 2 ** attemptIndex, 30000)
},
},
mutations: {
retry: false, // Never retry mutations (TanStack Query's default)
},
},
})

Per-query overrides still work as in plain TanStack Query:

import { useActorQuery } from "../reactor/hooks"
function usePrice() {
return useActorQuery({
functionName: "getPrice",
args: [],
retry: 5, // Retry up to 5 times
retryDelay: 1000, // Wait 1 second between retries
})
}
import { useActorQuery } from "../reactor/hooks"
function DataView() {
const { data, error, refetch, isError, isFetching } = useActorQuery({
functionName: "getData",
args: [],
})
if (isError) {
return (
<div className="error-state">
<p>Failed to load data</p>
<button onClick={() => refetch()} disabled={isFetching}>
{isFetching ? "Retrying..." : "Retry"}
</button>
</div>
)
}
return <DataDisplay data={data} />
}
import { useActorMutation } from "../reactor/hooks"
function CreateForm() {
const { mutate, error, isError, reset } = useActorMutation({
functionName: "create",
})
return (
<form>
{isError && (
<div className="error-banner">
<p>{error.message}</p>
<button type="button" onClick={reset}>
Dismiss
</button>
</div>
)}
{/* form fields */}
</form>
)
}

Provide fallback data when queries fail. placeholderData is not that: it is shown only while the query is pending, and data is undefined again once the query fails. Fall back explicitly instead:

import { useActorQuery } from "../reactor/hooks"
const DEFAULT_CONFIG = { theme: "light", language: "en" }
function Settings() {
const { data } = useActorQuery({
functionName: "getConfig",
args: [],
})
// Default config while loading and after a failure
const config = data ?? DEFAULT_CONFIG
return <p>Theme: {config.theme}</p>
}
import { QueryClient } from "@tanstack/react-query"
import { isRetryableReactorError, reactorRetry } from "@ic-reactor/react"
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
// Log all errors for debugging
console.error("Query failed:", {
failureCount,
error,
isRetryable: isRetryableReactorError(error),
})
return reactorRetry(failureCount, error)
},
},
},
})
import * as Sentry from "@sentry/react"
import { CanisterError } from "@ic-reactor/react"
import { useActorMutation } from "../reactor/hooks"
function TransferButton() {
const { mutate } = useActorMutation({
functionName: "transfer",
onError: (error) => {
// Report to error tracking
Sentry.captureException(error, {
tags: {
type: error instanceof CanisterError ? "canister" : "network",
},
})
},
})
return (
<button onClick={() => mutate([{ to: recipient, amount }])}>
Transfer
</button>
)
}
  1. Use onCanisterError for mutations — Separates canister Result { Err } variants from network/agent errors without manual instanceof checks
  2. Distinguish error types — Handle CanisterError, CallError, and ValidationError differently
  3. Don’t retry deterministic failures — If the canister says “insufficient funds”, retrying won’t help. reactorRetry (the default when defineReactor creates the QueryClient) classifies this for you
  4. Show actionable messages — Tell users how to fix the issue
  5. Provide recovery options — Retry buttons, form reset, etc.
  6. Log appropriately — Send business errors to analytics, network errors to monitoring
  7. Use type guards — Create helper functions for typed error checking
import { CanisterError, type ReactorErrorOf } from "@ic-reactor/react"
import { backend } from "../reactor"
// The `Err` value of `transfer`, and the case the guard below checks for
type TransferFailure = Extract<
ReactorErrorOf<typeof backend, "transfer">,
CanisterError
>["err"]
type InsufficientFunds = Extract<
TransferFailure,
{ _type: "InsufficientFunds" }
>
// Helper for checking specific error variants
function isInsufficientFunds(
error: unknown
): error is CanisterError<InsufficientFunds> {
return error instanceof CanisterError && error.code === "InsufficientFunds"
}
// Usage
function report(error: unknown) {
if (isInsufficientFunds(error)) {
const balance = error.err.InsufficientFunds.balance
// TypeScript knows the type is correct
}
}