Skip to content
IC Reactor

Errors and mayHaveExecuted

Every call the client makes rejects with a ReactorError, whether it is a direct call, a query function, a mutation function or a client.func call. It carries two things you need most: a kind that says what went wrong, and mayHaveExecuted, which says whether a write may have taken effect anyway.

ReactorError is a type. There is no class, so instanceof does not work. Test with isReactorError, which also recognises an error made by another copy of the package:

import { isReactorError } from "@ic-reactor/core"
import { ledger } from "./ledger"
try {
await ledger.icrc1_fee()
} catch (error) {
if (!isReactorError(error)) throw error // not ours: a bug, not a failed call
console.error(error.kind, error.mayHaveExecuted, error.message)
}

isReactorError narrows to ReactorError<unknown>. ReactorError<E> is the same error with a typed err. For a method whose result is variant { Ok : T; Err : E }, the error of a query or mutation is ReactorError<E>, and err is typed E exactly when kind is "canister_err". For a direct call you assert the type yourself.

Field Meaning
kind what went wrong: one of the eight kinds below
mayHaveExecuted whether the call may have taken effect although you cannot tell what it did
method the Candid method called
canisterId the target canister, as text
code a machine-readable sub-reason inside a kind, when there is one (see Codes)
rejectCode the IC reject code (1 to 6), when the call was rejected
httpStatus the HTTP status the replica or a boundary node answered with, when there was one
issues which parts of the arguments or the reply failed to encode or decode: { code, path, message } each
err the typed Err payload, only when kind is canister_err
cause the error that caused this one, when there is one (for example the agent’s error)
message one line: [ic-reactor] <method> on <canisterId>: <reason>
kind Meaning
invalid_args the arguments could not be encoded, or the target canister id could not be resolved. Nothing was sent
unauthenticated an update was attempted while nobody is signed in. Nothing was sent
not_delivered nothing that matters ran: the request was refused or never taken in, or it was a query that got no answer
outcome_unknown an update went out and no trustworthy answer came back, so the canister may or may not have run it
rejected the IC or the canister rejected the call; rejectCode says which
invalid_reply a reply arrived but did not decode as the method’s result
canister_err the canister ran the call and answered with the Err arm of its result; err is typed
cancelled the caller abandoned the call: an aborted call, a read whose caller is no longer current, or a call on a disposed client
  • true is a warning, not a verdict. The IC or the network left the outcome open: the canister may have changed state, and you cannot tell. Sending the write again could run it twice. Read the state back before you try again.
  • false means the outcome is known. The call was refused before it ran, or the canister ran it and answered Err. It does not mean “nothing changed” for canister_err: a canister may change state before it returns an Err. It means the answer is the decision, and sending again is a new decision, not a retry.

A query changes nothing, so a failed read never “may have executed”.

The client turns every failure of an agent into a kind with one table, for an update and for a query:

Failure Update: kind Update: mayHaveExecuted Query: kind
the call was aborted (an AbortError, or its signal aborted) cancelled true cancelled
reject code 1 or 3 rejected false rejected
reject code 2 not_delivered (retryable) false not_delivered (retryable)
reject code 1, 2 or 3 from the management canister (aaaaa-aa) rejected true rejected
reject code 4 or 5 rejected true rejected
reject code 6, an unknown code, HTTP 408 or 5xx, a network failure, a polling timeout, a failed certificate check outcome_unknown true not_delivered (retryable)
HTTP 429 not_delivered (retryable) false not_delivered (retryable)
any other HTTP 4xx, IngressExpiryInvalid not_delivered false not_delivered
a request that could not be built or signed not_delivered false not_delivered

A query’s mayHaveExecuted is false in every row.

Two rules sit behind the table.

  • Once the request may be in the IC, the HTTP rows change. The HTTP rows, IngressExpiryInvalid and the “could not be built” row hold for an update only before the request may have been accepted. After the replica answered 202 (the client is polling for the reply), a later refusal says nothing about the call, so all of those rows read outcome_unknown with mayHaveExecuted: true. This is why a 429 on the first send is safe to send again, and a 429 on a later poll is not.
  • The management canister is the exception for reject codes 1 to 3. For every other canister, codes 1 to 3 prove no canister code ran. For aaaaa-aa they prove nothing, so an update there may have executed.

The errors the client makes itself, without an agent error to classify, are simpler:

  • invalid_args, unauthenticated and canister_err always say false.
  • cancelled says false when nothing was sent. An update aborted in flight says true, as the first row of the table.
  • invalid_reply says true for an update, because the canister ran it and only the answer is unreadable, and false for a query.

code narrows a kind. The client sets exactly these eight. Only canister_not_found comes from the IC’s answer; the client decides the others itself:

code kind When
canister_id_unresolved invalid_args a { name } target the ic_env cookie cannot give: on a server, an untrusted host, or no such entry
no_certified_path invalid_args a composite_query called on a certified: true canister
effective_canister_id_unknown invalid_args a management canister call that cannot be routed from its arguments
anonymous_write unauthenticated an update or oneway method by a caller who is not signed in
identity_unavailable unauthenticated the auth’s getIdentity() rejected
caller_changed cancelled the call was made for a principal that is no longer current
client_disposed cancelled a call on a disposed client
canister_not_found not_delivered, rejected or outcome_unknown the IC answered that the canister does not exist: a boundary node’s HTTP 400 canister_not_found (an id outside every subnet’s range), or a replica’s reject 3 with IC error code IC0301 (an id inside a range that holds no canister). A write the IC may already hold keeps the code under outcome_unknown

An aborted call is cancelled with no code.

kind What to do
invalid_args Fix the input or the target. Read issues for the field and the reason. Nothing was sent.
unauthenticated Ask the user to sign in, then send again. anonymous_write means nobody is signed in. identity_unavailable means the auth could not hand over an identity.
not_delivered It is safe to try again. The client already retried a read, and sent an update again where that was proven safe, so this is the end of what it will do alone.
outcome_unknown Say the outcome is unknown. Read the state back before anything else. Never send again blindly.
rejected Read rejectCode. Code 4 is a reject the canister chose, and code 5 is a trap. If mayHaveExecuted is true, read the state back before sending again.
invalid_reply The generated module and the canister disagree about the method’s result: regenerate the module from the canister’s current .did. For an update, it did run.
canister_err Show or act on the typed err. The canister’s answer is final. Sending again is a new decision. A mutation has already refreshed its reads.
cancelled Usually ignore it: the caller moved on. A caller_changed read lands nowhere, and the component reads the new caller’s key. Ignore it in a global error handler as well.

For every mayHaveExecuted: true failure of a write, a mutation has already invalidated the canister’s reads, so the page shows what the canister holds when the re-read lands. See Writes.

A query’s or mutation’s error is ReactorError<E> | null. A function over the kinds is a good place for the wording. The never check makes TypeScript tell you when a kind is added:

import type { ReactorError } from "@ic-reactor/core"
export function describe(error: ReactorError<unknown>): string {
switch (error.kind) {
case "invalid_args":
return error.issues?.[0]?.message ?? "The input is not valid."
case "unauthenticated":
return "Sign in first."
case "not_delivered":
return "The request did not get through. Try again."
case "outcome_unknown":
return "No answer came back. It may have gone through: check before sending again."
case "rejected":
return error.mayHaveExecuted
? `Rejected (code ${error.rejectCode}). It may have run: check before sending again.`
: `Rejected (code ${error.rejectCode}).`
case "invalid_reply":
return "The canister's answer did not match its interface."
case "canister_err":
return "The canister refused."
case "cancelled":
return "Cancelled."
default: {
const unhandled: never = error
return String(unhandled)
}
}
}

A canister’s own Err is typed. For the ICRC-1 ledger’s icrc1_transfer, a mutation’s error narrows like this:

import { useClient } from "@ic-reactor/react"
import { useMutation } from "@tanstack/react-query"
import { actor, type Actor } from "./generated/icrc1"
export function TransferStatus() {
const client = useClient()
const ledger = client.canister<Actor>(actor, {
id: "ryjl3-tyaaa-aaaaa-aaaba-cai",
})
const transfer = useMutation(client.mutationOptions(ledger, "icrc1_transfer"))
const error = transfer.error // ReactorError<TransferError> | null
if (error?.kind === "canister_err" && error.err.tag === "InsufficientFunds") {
return <p>The balance is {error.err.value.balance.toString()}.</p>
}
return error ? <p>{error.message}</p> : null
}

After mayHaveExecuted: true, do not send the same write again until you know what happened. Two ways to know:

  • Read the state back. Read the balance, the profile or the list the write changed, and decide from what you find. In React, the mutation has already invalidated the canister’s reads, so the page shows it.
  • Let the canister deduplicate. If the method can tell a repeat from a new call, send the same argument again. The ICRC-1 ledger does: a second transfer from the same account identical to the first, created_at_time and memo included, is answered Duplicate, which means the first one went through. Give each transfer its own memo, or two different transfers of the same amount made in the same millisecond look like one, and the second is reported as done when it never ran. The client does not do this for you. It is the ledger’s feature, and the Vite wallet example uses it as its recipe.
import { isReactorError } from "@ic-reactor/core"
import type { Account, TransferError } from "./generated/icrc1"
import { ledger } from "./ledger"
/** Sends a transfer, and after an unknown outcome sends the same argument once more. */
export async function transferOnce(to: Account, amount: bigint) {
const arg = {
to,
amount,
fee: null,
// This transfer's own: only a repeat of this argument is a duplicate.
memo: crypto.getRandomValues(new Uint8Array(16)),
from_subaccount: null,
created_at_time: BigInt(Date.now()) * 1_000_000n, // nanoseconds; makes a repeat detectable
}
try {
return await ledger.icrc1_transfer(arg)
} catch (error) {
if (!isReactorError(error) || !error.mayHaveExecuted) throw error
try {
return await ledger.icrc1_transfer(arg) // the identical argument
} catch (second) {
if (isReactorError(second) && second.kind === "canister_err") {
const err = second.err as TransferError
if (err.tag === "Duplicate") return err.value.duplicate_of // the first one went through
}
throw second
}
}
}

Mapping kinds to exit codes or HTTP statuses

Section titled “Mapping kinds to exit codes or HTTP statuses”

A command-line tool or a route handler turns a kind into an exit code or an HTTP status. Type the table with satisfies Record<ReactorErrorKind, number>: a kind added later then fails the type check.

import { isReactorError, type ReactorErrorKind } from "@ic-reactor/core"
const STATUS = {
invalid_args: 400,
unauthenticated: 401,
rejected: 502,
invalid_reply: 502,
canister_err: 502,
outcome_unknown: 504,
not_delivered: 503,
cancelled: 503,
} as const satisfies Record<ReactorErrorKind, number>
export function statusOf(error: unknown): number {
return isReactorError(error) ? STATUS[error.kind] : 500
}

Two examples do this in full. The Node agent tool gives each kind an exit code and a sentence of advice. The Next.js SSR example has a route handler that maps kind to an HTTP status and uses httpStatus to tell a refusal that passes (429, 408, 5xx) from one that stands (any other 4xx).

A read built by one caller can be cancelled when another becomes current. If you log errors in one place, such as a listener on client.queryClient.getQueryCache(), skip the cancelled kind there. A component that reads with useQuery never shows it: it renders again under the new caller’s key. The SSR page shows the listener, and the one case where a read the hydrating render built is cancelled once.