# 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.

## Error Types

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

### CanisterError

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

```typescript
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.

### CallError

Thrown for network, agent, or canister trap errors:

```typescript
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.
  }
}
```

### ValidationError

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:

```typescript
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](https://ic-reactor.b3pay.net/v3/reference/reactvalidation) for the full surface.

## Handling Query Errors

### In Components

```tsx
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} />
}
```

### Error Boundaries

Use React Error Boundaries for unexpected errors. The [`react-error-boundary`](https://www.npmjs.com/package/react-error-boundary) package provides a convenient `ErrorBoundary` component:

```bash
pnpm add react-error-boundary
```

```tsx
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>
  )
}
```

## Handling Mutation Errors

### Using onCanisterError (Recommended)

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

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

  // ...
}
```

### Using onError Callback

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

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

  // ...
}
```

### Using try/catch with mutateAsync

```tsx
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>
}
```

## Typed Error Handling

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.

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

## Retry Configuration

### Built-in Retry Classification

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:

```typescript
import { QueryClient } from "@tanstack/react-query"
import { reactorRetry } from "@ic-reactor/react"

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: reactorRetry,
    },
  },
})
```

### Retrying Update Calls

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](https://ic-reactor.b3pay.net/v3/framework/queries#update-methods-in-queries).
Mutations retry nothing by default. To retry one only when that cannot run it
twice:

```typescript
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.

### Custom Retry Logic

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

```typescript
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:

```typescript
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
  })
}
```

## Error Recovery Patterns

### Retry Button

```tsx
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} />
}
```

### Reset Mutation State

```tsx
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>
  )
}
```

### Fallback Data

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:

```tsx
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>
}
```

## Logging and Monitoring

### Log Errors for Debugging

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

### Error Tracking Integration

```tsx
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>
  )
}
```

## Best Practices

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

```typescript
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
  }
}
```

## Further Reading

- [Queries](https://ic-reactor.b3pay.net/v3/framework/queries) — Query error handling patterns
- [Mutations](https://ic-reactor.b3pay.net/v3/framework/mutations) — Mutation error handling patterns
- [TanStack Query Error Handling](https://tanstack.com/query/latest/docs/framework/react/guides/query-functions#handling-errors) — TanStack Query documentation