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

feat: call and poll #941

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
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
10 changes: 7 additions & 3 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

## [Unreleased]

### Added

- feat: new `callAndPoll` function for an agent to call a canister and poll for the response

## Changed

- fix: recalculates body to use a fresh `Expiry` when polling for `read_state` requests. This prevents the request from exceeding the `maximum_ingress_expiry` when the replica is slow to respond.

## [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
Expand All @@ -21,6 +22,9 @@ AgentError: Call failed:
```
- feat: the `UpdateCallRejected` error now exposes `reject_code: ReplicaRejectCode`, `reject_message: string`, and `error_code?: string` properties directly on the error object.

## [2.1.2] - 2024-09-30
- fix: revert https://github.com/dfinity/agent-js/pull/923 allow option to set agent replica time

## [2.1.1] - 2024-09-13

### Added
Expand Down
19 changes: 19 additions & 0 deletions e2e/node/basic/callAndPoll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { HttpAgent, fromHex, callAndPoll } from '@dfinity/agent';
import { Principal } from '@dfinity/principal';
import { expect, describe, it, vi } from 'vitest';
describe('call and poll', () => {
it('should handle call and poll', async () => {
vi.useRealTimers();

const options = {
canister_id: Principal.from('tnnnb-2yaaa-aaaab-qaiiq-cai'),
method_name: 'inc_read',
agent: await HttpAgent.create({ host: 'https://icp-api.io' }),
arg: fromHex('4449444c0000'),
};

const { certificate, contentMap } = await callAndPoll(options);
expect(certificate instanceof ArrayBuffer).toBe(true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you going to enhance the tests? e.g. asserting that the certificate return is the expected value or asserting that the function correctly throws if there was an error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this should have been marked as a draft. I wanted feedback on the inputs and return values before proceeding. If you have real-world test cases this feature will be used for, those would be great to add as tests as well, beyond the basic cases!

expect(contentMap).toMatchInlineSnapshot();
});
});
6 changes: 6 additions & 0 deletions packages/agent/src/agent/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { RequestId } from '../request_id';
import { JsonObject } from '@dfinity/candid';
import { Identity } from '../auth';
import { CallRequest, HttpHeaderField, QueryRequest } from './http/types';
import { Expiry } from './http';

/**
* Codes used by the replica for rejecting a message.
Expand Down Expand Up @@ -115,6 +116,11 @@ export interface CallOptions {
* @see https://internetcomputer.org/docs/current/references/ic-interface-spec/#http-effective-canister-id
*/
effectiveCanisterId: Principal | string;

/**
* The expiry for the ingress message. Defaults to 4 minutes, rounded down to the nearest minute.
*/
ingressExpiry?: Expiry;
}

export interface ReadStateResponse {
Expand Down
17 changes: 12 additions & 5 deletions packages/agent/src/agent/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,12 +410,13 @@ export class HttpAgent implements Agent {
arg: ArrayBuffer;
effectiveCanisterId?: Principal | string;
callSync?: boolean;
ingressExpiry?: Expiry;
},
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 All @@ -428,11 +429,16 @@ export class HttpAgent implements Agent {

const sender: Principal = id.getPrincipal() || Principal.anonymous();

let ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS);
let ingress_expiry: Expiry;

// If the value is off by more than 30 seconds, reconcile system time with the network
if (Math.abs(this.#timeDiffMsecs) > 1_000 * 30) {
// use a provided ingress expiry if it is defined
if (options.ingressExpiry) {
ingress_expiry = options.ingressExpiry;
} else if (Math.abs(this.#timeDiffMsecs) > 1_000 * 30) {
// If the value is off by more than 30 seconds, reconcile system time with the network
ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS + this.#timeDiffMsecs);
} else {
ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS);
}

const submit: CallRequest = {
Expand Down Expand Up @@ -516,6 +522,7 @@ export class HttpAgent implements Agent {

// Update the watermark with the latest time from consensus
if (responseBody && 'certificate' in (responseBody as v3ResponseBody)) {
responseBody['certificate'] = bufFromBufLike(responseBody['certificate']);
const time = await this.parseTimeFromResponse({
certificate: (responseBody as v3ResponseBody).certificate,
});
Expand Down Expand Up @@ -756,7 +763,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 ? identity : 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
13 changes: 13 additions & 0 deletions packages/agent/src/agent/http/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ export interface HttpAgentRequestTransformFn {
priority?: number;
}

export interface ContentMap {
request_type: 'call' | 'query' | 'read_state';
canister_id: Principal;
method_name: string;
arg: ArrayBuffer;
sender: Uint8Array | Principal;
ingress_expiry: Expiry;
nonce?: Nonce;
}

// The fields in a "call" submit request.
export interface CallRequest extends Record<string, any> {
request_type: SubmitRequestType.Call;
Expand Down Expand Up @@ -121,3 +131,6 @@ export function makeNonce(): Nonce {

return buffer as Nonce;
}

export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
4 changes: 3 additions & 1 deletion packages/agent/src/certificate.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as cbor from './cbor';
import { AgentError } from './errors';
import { hash } from './request_id';
import { bufEquals, concat, fromHex, toHex } from './utils/buffer';
import { bufEquals, bufFromBufLike, concat, fromHex, toHex } from './utils/buffer';
import { Principal } from '@dfinity/principal';
import * as bls from './utils/bls';
import { decodeTime } from './utils/leb';
Expand Down Expand Up @@ -150,6 +150,7 @@ export interface CreateCertificateOptions {

export class Certificate {
public cert: Cert;
public readonly rawCert: ArrayBuffer;

/**
* Create a new instance of a certificate, automatically verifying it. Throws a
Expand Down Expand Up @@ -191,6 +192,7 @@ export class Certificate {
// Default to 5 minutes
private _maxAgeInMinutes: number = 5,
) {
this.rawCert = bufFromBufLike(certificate);
this.cert = cbor.decode(new Uint8Array(certificate));
}

Expand Down
22 changes: 22 additions & 0 deletions packages/agent/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { v2ResponseBody, v3ResponseBody } from './agent';
import { HttpHeaderField } from './agent/http/types';
import { RequestId } from './request_id';

/**
* An error that happens in the Agent. This is the root of all errors and should be used
* everywhere in the Agent code (this package).
Expand All @@ -10,3 +14,21 @@ export class AgentError extends Error {
Object.setPrototypeOf(this, AgentError.prototype);
}
}

export class AgentCallError extends AgentError {
public name = 'AgentCallError';
constructor(
public readonly message: string,
public readonly response: {
ok: boolean;
status: number;
statusText: string;
body: v2ResponseBody | v3ResponseBody | null;
headers: HttpHeaderField[];
},
public readonly requestId: RequestId,
) {
super(message);
Object.setPrototypeOf(this, AgentCallError.prototype);
}
}
1 change: 1 addition & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './der';
export * from './fetch_candid';
export * from './public_key';
export * from './request_id';
export * from './utils/callAndPoll';
export * from './utils/bls';
export * from './utils/buffer';
export * from './utils/random';
Expand Down
138 changes: 138 additions & 0 deletions packages/agent/src/utils/callAndPoll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { Principal } from '@dfinity/principal';
import {
Agent,
Certificate,
ContentMap,
Expiry,
PartialBy,
bufFromBufLike,
polling,
v3ResponseBody,
} from '..';
import { AgentCallError, AgentError } from '../errors';
import { isArrayBuffer } from 'util/types';
import { DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS } from '../constants';

export type CallAndPollOptions = PartialBy<
Omit<ContentMap, 'sender' | 'request_type'>,
'ingress_expiry'
> & {
agent: Agent;
};

/**
* Call a canister using the v3 api and either return the response or fall back to polling
* @param options - The options to use when calling the canister
* @param options.canister_id - The canister id to call
* @param options.method_name - The method name to call
* @param options.agent - The agent to use to make the call
* @param options.arg - The argument to pass to the canister
* @returns The certificate response from the canister (which includes the reply)
*/
export async function callAndPoll(options: CallAndPollOptions): Promise<{
certificate: ArrayBuffer;
contentMap: ContentMap;
}> {
assertContentMap(options);
const { canister_id, method_name, agent, arg } = options;
const cid = Principal.from(options.canister_id);

const { defaultStrategy } = polling.strategy;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be useful for polling strategy to be an option?


if (agent.rootKey == null) throw new Error('Agent root key not initialized before making call');

const ingress_expiry =
options.ingress_expiry ?? new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS);

const contentMap: ContentMap = {
canister_id: Principal.from(canister_id),
request_type: 'call',
method_name: method_name,
arg,
sender: await agent.getPrincipal(),
ingress_expiry,
};
const { requestId, response } = await agent.call(cid, {
methodName: method_name,
arg,
effectiveCanisterId: cid,
ingressExpiry: ingress_expiry,
});

if (response.status === 200) {
if ('body' in response) {
// Ensure the response body is a v3 response
assertV3ResponseBody(response.body);

const cert = response.body.certificate;
// Create certificate to validate the responses
const certificate = await Certificate.create({
certificate: bufFromBufLike(cert),
rootKey: agent.rootKey,
canisterId: Principal.from(canister_id),
});

return {
certificate: certificate.rawCert,
contentMap,
};
} else {
throw new AgentCallError(
'unexpected call error: no certificate in response',
response,
requestId,
);
}
}
// Fall back to polling if we recieve an Accepted response code
else if (response.status === 202) {
const pollStrategy = defaultStrategy();
// Contains the certificate and the reply from the boundary node
const response = await polling.pollForResponse(agent, cid, requestId, pollStrategy);
return {
certificate: response.certificate.rawCert,
contentMap,
};
} else {
console.error('The network returned a response but the result could not be determined.', {
response,
requestId,
});
throw new AgentError('unexpected call error: no certificate in response');
}
}

function assertContentMap(contentMap: unknown): asserts contentMap is ContentMap {
if (!contentMap || typeof contentMap !== 'object') {
throw new AgentError('unexpected call error: no contentMap provided for call');
}
if (!('canister_id' in contentMap)) {
throw new AgentError('unexpected call error: no canister_id provided for call');
}
if (!('method_name' in contentMap)) {
throw new AgentError('unexpected call error: no method_name provided for call');
}
if (!('arg' in contentMap)) {
throw new AgentError('unexpected call error: no arg provided for call');
}
}

function assertV3ResponseBody(body: unknown): asserts body is v3ResponseBody {
if (!body || typeof body !== 'object') {
throw new AgentError('unexpected call error: no body in response');
}
if (!('certificate' in body)) {
throw new AgentError('unexpected call error: no certificate in response');
}
if (body['certificate'] === undefined || body['certificate'] === null) {
throw new AgentError('unexpected call error: certificate is not an ArrayBuffer');
}
try {
const cert = bufFromBufLike(body['certificate'] as ArrayBufferLike);
if (!isArrayBuffer(cert)) {
throw new AgentError('unexpected call error: certificate is not an ArrayBuffer');
}
} catch (error) {
throw new AgentError('unexpected call error: while presenting certificate: ' + error);
}
}
Loading