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.
// 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:
throwis rejected in verification mode. Use a normal return value (e.g.,Result) or atrapif 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.
// 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:
// 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:
// 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 Code | Description |
|---|---|
#canister_reject | Error thrown by canister code (your throw statements) |
#system_fatal | Unrecoverable system error |
#system_transient | Temporary system error, may succeed on retry |
#destination_invalid | Target canister does not exist |
#canister_error | General canister error |
#system_unknown | Unknown 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
}
// 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 Allowed | Error |
|---|---|
await | M0047: send capability not available |
throw | M0039: misplaced throw |
return | M0085: misplaced return |
break | M0083: label not in scope |
| Non-unit return | M0050: 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:
// 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:
throwis rejected in verified code (use a return value or a trap instead)try/catchmust be catch-all (refutable patterns are rejected)- Suspending awaits must be caught (
awaitand anyawait*that may reach a realawaitmust be inside atry/catch) - Error values cannot escape (no explicit
Errortypes/values outside a catch binder) finallyruns on both paths and may mutate state, but still cannotawait,return,break, orthrow
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
| Construct | Purpose | Runtime Effect | Verification |
|---|---|---|---|
throw | Signal recoverable error | Propagates up call stack | Rejected in verification |
trap | Signal impossible condition | Terminates execution | Must prove a declared trap condition or prove the path unreachable |
runtime:assert | Defense-in-depth check | Traps if false | Must prove condition true |
assert | Document knowledge | No runtime effect | Must 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 bodyfinally { ... }always runs and may mutate state, but cannotawait,throw,return, orbreak- Error handling requires async context (M0039 otherwise)
Error.code(e)andError.message(e)extract error information- Only
#canister_rejectis 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
Related Topics
- Trap specifications for verified unrecoverable failures
- Runtime assertions for defense-in-depth checks
- Async and await for suspension boundaries and required catch handlers