Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: trap and throw handling in v3 sync call #940

Merged
merged 4 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
krpeacock marked this conversation as resolved.
Show resolved Hide resolved
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
Loading