Skip to main content

Error Handling

Error handling with try/catch/finally provides structured recovery from failures in async contexts. General Sector9 code can throw errors, but verification mode rejects throw; verified code should model recoverable failure with return values or explicit trap contracts instead.

Async Context Requirement

Error handling constructs (try, catch, throw) require an async context. You cannot use them in synchronous functions or at module level:

// Valid - inside async function
public func process() : async Nat {
try { await riskyOperation() } catch (e) { 0 }
};

// Invalid - synchronous context
func syncProcess() : Nat {
throw Error.reject("no") // Error: misplaced throw
};

The compiler enforces this restriction with error code M0039: "misplaced throw/try; try enclosing in an async expression or query function".

Throwing Errors

Use throw to signal an error condition. The thrown expression must be of type Error:

throw Error.reject("error message")

The throw expression has type Non (bottom type) because execution never continues past it. Create errors with Error.reject(message) from mo:core/Error, where message is a Text value.

error-throw.sr9
// Throwing errors with custom messages
import Error "mo:core/Error";

persistent actor {
var balance : Nat = 100;

public func withdraw(amount : Nat) : async Nat
modifies balance
{
if (amount > balance) {
throw Error.reject("insufficient funds");
};
balance -= amount;
balance
};

public func safeWithdraw(amount : Nat) : async Nat
modifies balance
{
try {
await withdraw(amount)
} catch (e) {
balance // Return unchanged balance on error
}
};
};

Verification note: throw is rejected in verification mode. Use a normal return value (e.g., Result) or a trap if the condition should be unreachable.

The withdraw function throws when the requested amount exceeds the balance. The safeWithdraw wrapper catches this error and returns the unchanged balance instead.

Catching Errors

The try/catch construct catches errors thrown within its body:

try {
// Code that might throw
} catch (e) {
// Handle the error
}

The catch clause binds the error to a variable (here e). Use catch _ to ignore the error value.

error-basic.sr9
// Basic try/catch usage in async context
import Error "mo:core/Error";

persistent actor {
public func divide(a : Nat, b : Nat) : async Nat {
if (b == 0) {
throw Error.reject("division by zero");
};
a / b
};

public func safeDivide(a : Nat, b : Nat) : async Nat {
try {
await divide(a, b)
} catch (e) {
0 // Return default on error
}
};
};

Try as Expression

The try construct is an expression that returns a value. The type is the least upper bound of the body type and all catch handler types:

error-expression.sr9
// Try as an expression returning a value
import Error "mo:core/Error";

persistent actor {
public func riskyCompute() : async Nat {
throw Error.reject("computation failed")
};

public func compute(useDefault : Bool) : async Nat {
// Try expression returns value from body or catch
let result = try {
await riskyCompute()
} catch (e) {
42 // Default value on error
};
result
};

public func computeConditional(flag : Bool) : async Nat {
let value = try {
if (flag) { 10 } else { 20 }
} catch (e) {
0
};
value
};
};

Both branches must produce compatible types. If the body returns Nat, the catch handler must also return Nat.

Error Codes

Errors carry an error code that indicates the error category. Use Error.code(e) to extract the code and Error.message(e) for the message:

error-codes.sr9
// Matching on error codes
import Error "mo:core/Error";

persistent actor {
public func handleError() : async Text {
try {
throw Error.reject("something went wrong")
} catch (e) {
switch (Error.code(e)) {
case (#canister_reject) {
"Canister rejected: " # Error.message(e)
};
case (#system_fatal) { "Fatal system error" };
case (#system_transient) { "Transient error, retry" };
case (#destination_invalid) { "Invalid destination" };
case (#canister_error) { "Canister error" };
case (#system_unknown) { "Unknown system error" };
case (#future _) { "Future error code" };
case (#call_error _) { "Call error" };
}
}
};
};

Error Code Categories

Error CodeDescription
#canister_rejectError thrown by canister code (your throw statements)
#system_fatalUnrecoverable system error
#system_transientTemporary system error, may succeed on retry
#destination_invalidTarget canister does not exist
#canister_errorGeneral canister error
#system_unknownUnknown system error
#future(n)Reserved for future error codes
#call_error{err_code}Inter-canister call failure with numeric code

When you throw, the error code is always #canister_reject. The other codes come from the IC runtime for system-level failures.

Finally Blocks

The finally clause runs after both successful and error paths:

try {
// Code that might throw
} catch (e) {
// Handle error
} finally {
// Always runs
}
error-finally.sr9
// Try/catch/finally pattern
import Error "mo:core/Error";

persistent actor {
var resourceLocked : Bool = false;
var counter : Nat = 0;

func riskyOperation() : async () {
if (counter > 5) {
throw Error.reject("counter too high");
};
counter += 1;
};

public func doWork() : async ()
modifies resourceLocked, counter
{
resourceLocked := true;
try {
await riskyOperation()
} catch (e) {
// Handle error silently
} finally {
// Always runs - cannot await, throw, return, or break.
counter += 1;
};
resourceLocked := false;
};
};

Finally Restrictions

Finally blocks have strict restrictions to prevent interference with error propagation:

Not AllowedError
awaitM0047: send capability not available
throwM0039: misplaced throw
returnM0085: misplaced return
breakM0083: label not in scope
Non-unit returnM0050: expected type ()

Finally blocks must:

  • Return type () (unit)
  • Not suspend (no await)
  • Not transfer control outward (no return, break, throw)
  • May mutate state for cleanup when the surrounding function declares the needed effects
// Invalid finally blocks
finally { await m() } // Cannot await
finally { throw e } // Cannot throw
finally { return } // Cannot return
finally { 42 } // Must return unit

// Allowed cleanup
finally { counter += 1 }

Rethrowing Errors

Catch an error, perform some action (like logging), then rethrow:

error-rethrow.sr9
// Catching, logging, and rethrowing errors
import Debug "mo:core/Debug";
import Error "mo:core/Error";

persistent actor {
public func process(n : Nat) : async Nat {
if (n == 0) {
throw Error.reject("cannot process zero");
};
n * 2
};

public func processWithLogging(n : Nat) : async Nat {
try {
await process(n)
} catch (e) {
Debug.print("Error: " # Error.message(e));
throw e // Rethrow after logging
}
};
};

The error continues propagating after the rethrow. The caller must handle it or let it propagate further.

Verification note: throw (including rethrow) is rejected in verification mode.

Verification Behavior

In verification mode, error handling is restricted:

  • throw is rejected in verified code (use a return value or a trap instead)
  • try/catch must be catch-all (refutable patterns are rejected)
  • Suspending awaits must be caught (await and any await* that may reach a real await must be inside a try/catch)
  • Error values cannot escape (no explicit Error types/values outside a catch binder)
  • finally runs on both paths and may mutate state, but still cannot await, return, break, or throw

This means verification treats try/catch as ordinary control flow and verifies both branches.

public func example() : async Nat
ensures result > 0;
{
let x = try { await compute() } catch (_) { 0 };
x + 1 // Verified: both success and fallback paths imply result > 0
}

For critical error handling logic, use runtime:assert in catch handlers to get runtime checking, or return a domain Result variant when the failure is part of the protocol.

Comparison with Other Constructs

ConstructPurposeRuntime EffectVerification
throwSignal recoverable errorPropagates up call stackRejected in verification
trapSignal impossible conditionTerminates executionMust prove a declared trap condition or prove the path unreachable
runtime:assertDefense-in-depth checkTraps if falseMust prove condition true
assertDocument knowledgeNo runtime effectMust prove condition true

Use throw for recoverable errors where the caller can handle the failure. Use trap for conditions that should never occur.

Common Patterns

Guard with Early Throw

Check preconditions and throw early:

public func process(input : ?Nat) : async Nat {
let value = switch (input) {
case null { throw Error.reject("input required") };
case (?v) { v };
};
value * 2
}

Default Value on Error

Return a safe default when the operation fails:

let result = try { await riskyComputation() } catch (e) { 0 };

Error Translation

Outside verification mode, you can translate low-level errors to domain-specific thrown errors. In verified code, prefer returning an explicit result variant instead of throwing.

try {
await externalService()
} catch (e) {
throw Error.reject("service unavailable: " # Error.message(e))
}

Cleanup After Error

Use state transitions around try/catch (not in finally due to restrictions):

inProgress := true;
try {
await operation()
} catch (e) {
// Handle error
};
inProgress := false;

Common Mistakes

Using Error Handling in Sync Context

// Wrong - no async context
func bad() : Nat {
try { 1 } catch (e) { 0 } // Error: misplaced try
}

// Correct - async function
func good() : async Nat {
try { 1 } catch (e) { 0 }
}

Effects in Finally

// Wrong - finally cannot suspend or transfer control
try { await m() }
catch _ {}
finally { await m() } // Cannot await

// Correct - cleanup inside finally is allowed
try { await m() } catch _ {}
finally { counter += 1 }

Ignoring Error Information

// Less helpful - discards error details
catch _ { 0 }

// Better - log or use error information
catch (e) {
Debug.print("Failed: " # Error.message(e));
0
}

Type Mismatch in Branches

// Wrong - branches have different types
let x = try { "hello" } catch (e) { 42 }; // Text vs Nat

// Correct - same type in both branches
let x = try { 10 } catch (e) { 42 }; // Both Nat

Summary

  • throw Error.reject(msg) signals a recoverable error (not supported in verification)
  • try { ... } catch (e) { ... } handles errors from the body
  • finally { ... } always runs and may mutate state, but cannot await, throw, return, or break
  • Error handling requires async context (M0039 otherwise)
  • Error.code(e) and Error.message(e) extract error information
  • Only #canister_reject is thrown by user code; other codes are system errors
  • Verification rejects throw, requires catch-all handlers, and verifies both branches
  • Finally blocks must return unit