Skip to content

Commit

Permalink
fix: trap and throw handling in v3 sync call (#940)
Browse files Browse the repository at this point in the history
* fix: trap and throw handling in v3 sync call
* adds dfx deploy to github e2e job
  • Loading branch information
krpeacock authored Oct 11, 2024
1 parent 61f8155 commit c73e4a9
Show file tree
Hide file tree
Showing 9 changed files with 146 additions and 20 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ jobs:
run: |
dfx start --background
- name: deploy canisters
run: |
dfx deploy counter
dfx deploy trap
- name: Node.js e2e tests
run: npm run e2e --workspace e2e/node
env:
Expand Down
4 changes: 4 additions & 0 deletions dfx.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
"counter": {
"type": "motoko",
"main": "e2e/node/canisters/counter.mo"
},
"trap": {
"type": "motoko",
"main": "e2e/node/canisters/trap.mo"
}
}
}
12 changes: 12 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@

## [2.1.2] - 2024-09-30
- fix: revert https://github.com/dfinity/agent-js/pull/923 allow option to set agent replica time
- fix: handle v3 traps correctly, pulling the reject_code and message from the certificate in the error response like v2.
Example trap error message:
```txt
AgentError: Call failed:
Canister: hbrpn-74aaa-aaaaa-qaaxq-cai
Method: Throw (update)
"Request ID": "ae107dfd7c9be168a8ebc122d904900a95e3f15312111d9e0c08f136573c5f13"
"Error code": "IC0406"
"Reject code": "4"
"Reject message": "foo"
```
- feat: the `UpdateCallRejected` error now exposes `reject_code: ReplicaRejectCode`, `reject_message: string`, and `error_code?: string` properties directly on the error object.

## [2.1.1] - 2024-09-13

Expand Down
49 changes: 49 additions & 0 deletions e2e/node/basic/trap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { ActorMethod, Actor, HttpAgent } from '@dfinity/agent';
import util from 'util';
import exec from 'child_process';
const execAsync = util.promisify(exec.exec);

const { stdout } = await execAsync('dfx canister id trap');

export const idlFactory = ({ IDL }) => {
return IDL.Service({
Throw: IDL.Func([], [], []),
test: IDL.Func([], [], []),
});
};

export interface _SERVICE {
Throw: ActorMethod<[], undefined>;
test: ActorMethod<[], undefined>;
}

describe('trap', () => {
it('should trap', async () => {
const canisterId = stdout.trim();
const agent = await HttpAgent.create({
host: 'http://localhost:4943',
shouldFetchRootKey: true,
});
const actor = Actor.createActor<_SERVICE>(idlFactory, { canisterId, agent });
try {
await actor.Throw();
} catch (error) {
console.log(error);
expect(error.reject_message).toBe('foo');
}
});
it('should trap', async () => {
const canisterId = stdout.trim();
const agent = await HttpAgent.create({
host: 'http://localhost:4943',
shouldFetchRootKey: true,
});
const actor = Actor.createActor<_SERVICE>(idlFactory, { canisterId, agent });
try {
await actor.test();
} catch (error) {
expect(error.reject_message).toContain('Canister called `ic0.trap` with message: trapping');
}
});
});
23 changes: 23 additions & 0 deletions e2e/node/canisters/trap.mo
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Debug "mo:base/Debug";
import Error "mo:base/Error";

actor {

func doTrap (n:Nat) {
if (n <= 0)
{ Debug.trap("trapping") }
else {
doTrap (n - 1);
};
Debug.print (debug_show {doTrap = n}); // prevent TCO
};

public func test() : async () {
doTrap(10);
};

public func Throw() : async () {
throw Error.reject("foo");
}

};
Binary file added e2e/node/canisters/trap.wasm
Binary file not shown.
42 changes: 34 additions & 8 deletions packages/agent/src/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
QueryResponseStatus,
ReplicaRejectCode,
SubmitResponse,
v3ResponseBody,
} from './agent';
import { AgentError } from './errors';
import { bufFromBufLike, IDL } from '@dfinity/candid';
Expand Down Expand Up @@ -56,18 +57,21 @@ export class UpdateCallRejectedError extends ActorCallError {
methodName: string,
public readonly requestId: RequestId,
public readonly response: SubmitResponse['response'],
public readonly reject_code: ReplicaRejectCode,
public readonly reject_message: string,
public readonly error_code?: string,
) {
super(canisterId, methodName, 'update', {
'Request ID': toHex(requestId),
...(response.body
? {
...(response.body.error_code
...(error_code
? {
'Error code': response.body.error_code,
'Error code': error_code,
}
: {}),
'Reject code': String(response.body.reject_code),
'Reject message': response.body.reject_message,
'Reject code': String(reject_code),
'Reject message': reject_message,
}
: {
'HTTP status code': response.status.toString(),
Expand Down Expand Up @@ -535,8 +539,8 @@ function _createActorMethod(
});
let reply: ArrayBuffer | undefined;
let certificate: Certificate | undefined;
if (response.body && response.body.certificate) {
const cert = response.body.certificate;
if (response.body && (response.body as v3ResponseBody).certificate) {
const cert = (response.body as v3ResponseBody).certificate;
certificate = await Certificate.create({
certificate: bufFromBufLike(cert),
rootKey: agent.rootKey,
Expand All @@ -552,8 +556,30 @@ function _createActorMethod(
case 'replied':
reply = lookupResultToBuffer(certificate.lookup([...path, 'reply']));
break;
case 'rejected':
throw new UpdateCallRejectedError(cid, methodName, requestId, response);
case 'rejected': {
// Find rejection details in the certificate
const rejectCode = new Uint8Array(
lookupResultToBuffer(certificate.lookup([...path, 'reject_code']))!,
)[0];
const rejectMessage = new TextDecoder().decode(
lookupResultToBuffer(certificate.lookup([...path, 'reject_message']))!,
);
const error_code_buf = lookupResultToBuffer(
certificate.lookup([...path, 'error_code']),
);
const error_code = error_code_buf
? new TextDecoder().decode(error_code_buf)
: undefined;
throw new UpdateCallRejectedError(
cid,
methodName,
requestId,
response,
rejectCode,
rejectMessage,
error_code,
);
}
}
}
// Fall back to polling if we receive an Accepted response code
Expand Down
18 changes: 11 additions & 7 deletions packages/agent/src/agent/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,19 +121,23 @@ export interface ReadStateResponse {
certificate: ArrayBuffer;
}

export interface v2ResponseBody {
error_code?: string;
reject_code: number;
reject_message: string;
}

export interface v3ResponseBody {
certificate: ArrayBuffer;
}

export interface SubmitResponse {
requestId: RequestId;
response: {
ok: boolean;
status: number;
statusText: string;
body: {
error_code?: string;
reject_code: number;
reject_message: string;
// Available in a v3 call response
certificate?: ArrayBuffer;
} | null;
body: v2ResponseBody | v3ResponseBody | null;
headers: HttpHeaderField[];
};
requestDetails?: CallRequest;
Expand Down
13 changes: 8 additions & 5 deletions packages/agent/src/agent/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ReadStateOptions,
ReadStateResponse,
SubmitResponse,
v3ResponseBody,
} from '../api';
import { Expiry, httpHeadersTransform, makeNonceTransform } from './transforms';
import {
Expand Down Expand Up @@ -414,8 +415,9 @@ export class HttpAgent implements Agent {
},
identity?: Identity | Promise<Identity>,
): Promise<SubmitResponse> {
// TODO - restore this value
const callSync = options.callSync ?? true;
const id = await (identity !== undefined ? await identity : await this.#identity);
const id = await(identity !== undefined ? await identity : await this.#identity);
if (!id) {
throw new IdentityInvalidError(
"This identity has expired due this application's security policy. Please refresh your authentication.",
Expand Down Expand Up @@ -499,7 +501,6 @@ export class HttpAgent implements Agent {
});
};


const request = this.#requestAndRetry({
request: callSync ? requestSync : requestAsync,
backoff,
Expand All @@ -516,8 +517,10 @@ export class HttpAgent implements Agent {
) as SubmitResponse['response']['body'];

// Update the watermark with the latest time from consensus
if (responseBody?.certificate) {
const time = await this.parseTimeFromResponse({ certificate: responseBody.certificate });
if (responseBody && 'certificate' in (responseBody as v3ResponseBody)) {
const time = await this.parseTimeFromResponse({
certificate: (responseBody as v3ResponseBody).certificate,
});
this.#waterMark = time;
}

Expand Down Expand Up @@ -755,7 +758,7 @@ export class HttpAgent implements Agent {
this.log.print(`ecid ${ecid.toString()}`);
this.log.print(`canisterId ${canisterId.toString()}`);
const makeQuery = async () => {
const id = await (identity !== undefined ? await identity : await this.#identity);
const id = await(identity !== undefined ? identity : this.#identity);
if (!id) {
throw new IdentityInvalidError(
"This identity has expired due this application's security policy. Please refresh your authentication.",
Expand Down

0 comments on commit c73e4a9

Please sign in to comment.