Just to recap:
TLDR: Try/catch do not work like they do in Javascript. In Motoko use them exclusively for their own asynchronous logic.
Each try/catch (at least currently and for the foreseeable future) should only be used āexclusivelyā for its own piece of logic that might trap. In other words, when wrapping an intercanister call, it should only wrap that and nothing else since anything else wonāt be caught (such as a division of zero done locally after the await has returned) and multiple separate intercanisters calls made within the same function should each have their own try/catch.
New changes to Motoko (as of 0.8.0) enable catching most (if not almost all) traps of an external canister call. It should be noted this introduces a possible breaking change:
ā#OpenIssues(Feedbackwanted)ā One minor worry with this PR is that it is a breaking change. Previously a block of code that sends multiple messages, one of which fails to send, would trap, rolling back all the sends to the previous commit point. Now, the successful sends will remain enqueued, and the error/exception will flow to the nearest handler. If there is none, the method will exit with a canister_reject error, which will still send the other enqueued messages and commit state changes.
Finally, in this case of a lock being used, thereās not yet a built-in available way to guarantee its release (however that may change); and a single try/catch within a methodās block should not be used to wrap associated logic to release the lock in the event of trapping.
In the meantime, one way to reliably release such a lock would be to use a timeout so when a subsequent check happens to see if the lock is in use, if enough time has elapsed, itāll automatically release. Hereās an example of implementing one adapted from something I was working that also reflects the lessons learned from this thread about proper use of try/catch. In this example, a refund is being processed that requires both a balance and transfer call to a token-ledger canister (it has not actually been tested, but something very close was, some methods/fields omitted):
let isAlreadyRefundingLookup = HashMap.HashMap<Principal, Time.Time>(32, Principal.equal, Principal.hash);
let isAlreadyRefundingTimeout : Nat = 600000000000; // "10 minutes ns"
func isAlreadyRefunding(caller : Principal) : Bool {
switch (isAlreadyRefundingLookup.get(caller)) {
case null return false;
case (?atTime) {
if ((Time.now() - atTime) >= isAlreadyRefundingTimeout) {
isAlreadyRefundingLookup.delete(caller);
return false;
} else {
true;
};
};
};
};
public type RefundResult = Result.Result<RefundResultSuccess, RefundResultErr>;
public type RefundResultSuccess = {
txIndex : Nat;
refundedAmount : Nat;
};
public type RefundResultErr = {
kind : {
#InProgress;
#InsufficientTransferAmount : Nat;
#InvalidDestination;
#NotAuthorized;
#NoBalance;
#ICRC1TransferErr;
#CaughtException : Text;
};
};
public shared ({ caller }) func refund(to : Account) : async Result.Result<RefundResultSuccess, RefundResultErr> {
if (isAlreadyRefunding(caller)) {
return #err({ kind = #InProgress });
};
// Note if getting the address involved computation for subaccount,
// or otherwise caused a trap (division by zero for instance) this
// would not be caught in this method. Not as important here, but below
// when transferring, notice check to see balance > fee before the
// try wrapping the transfer call.
let account = getAddress(caller);
isAlreadyRefundingLookup.put(caller, Time.now());
let balanceCallResponse : Result.Result<Nat, Text> = try {
#ok(await Ledger_ICRC1_Example.icrc1_balance_of(account));
} catch e {
// Might also want to include the error code or reformulate error as valid return type.
#err("Caught during balance query: " #Error.message(e));
};
switch balanceCallResponse {
case (#err err) {
// Unlock and return the caught & trapped intercanister call.
isAlreadyRefundingLookup.delete(caller);
return #err({ kind = #CaughtException(err) });
};
case (#ok currentBalance) {
// Prevent trapping from underflow
if (currentBalance < ICRC1_FEE) {
if (currentBalance == 0) {
isAlreadyRefundingLookup.delete(caller);
return #err({ kind = #NoBalance });
} else {
isAlreadyRefundingLookup.delete(caller);
return #err({ kind = #InsufficientTransferAmount(currentBalance) });
};
} else {
let transferAmount : Nat = currentBalance - ICRC1_FEE;
switch (getTransferArgsIfValidDestination(caller, transferAmount, to)) {
case (#err invalidToAccount) {
// Validation of the given to account failed, eg subaccount was an empty array.
// Important to confirm to prevent trapping when calling ICRC1 ledger.
isAlreadyRefundingLookup.delete(caller);
#err({ kind = #InvalidDestination });
};
case (#ok transferArgs) {
let transferCallResponse : Result.Result<Result<Tokens, TransferError>, Text> = try {
#ok(await Ledger_ICRC1_Example.icrc1_transfer(transferArgs));
} catch e {
// Might also want to include the error code or reformulate error as valid return type.
isAlreadyRefundingLookup.delete(caller);
#err("Caught during transfer:" # Error.message(e));
};
switch (transferCallResponse) {
case (#err errMsg) {
isAlreadyRefundingLookup.delete(caller);
#err({ kind = #CaughtException(errMsg) });
};
case (#ok transferResult) {
switch transferResult {
case (#Err _) {
// Or each specific TransferError could be parsed with its own case in this switch
isAlreadyRefundingLookup.delete(caller);
#err({ kind = #ICRC1TransferErr });
};
case (#Ok txIndex) {
isAlreadyRefundingLookup.delete(caller);
#ok({ txIndex; refundedAmount = transferAmount });
};
};
};
};
};
};
};
};
};
};