Skip to content

Commit

Permalink
feat: allow option set agent replica time (#923)
Browse files Browse the repository at this point in the history
Adds a new feature to set and read a specified replicaTime on the HttpAgent

Also improves the error handling for replica time errors
  • Loading branch information
krpeacock authored Sep 11, 2024
1 parent dae3812 commit a973a4d
Show file tree
Hide file tree
Showing 14 changed files with 260 additions and 37 deletions.
80 changes: 78 additions & 2 deletions e2e/node/basic/mainnet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
fromHex,
polling,
requestIdOf,
ReplicaTimeError,
} from '@dfinity/agent';
import { IDL } from '@dfinity/candid';
import { Ed25519KeyIdentity } from '@dfinity/identity';
Expand All @@ -21,7 +22,7 @@ const createWhoamiActor = async (identity: Identity) => {
const idlFactory = () => {
return IDL.Service({
whoami: IDL.Func([], [IDL.Principal], ['query']),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as unknown as any;
};
vi.useFakeTimers();
Expand Down Expand Up @@ -142,7 +143,6 @@ describe('call forwarding', () => {
}, 15_000);
});


test('it should allow you to set an incorrect root key', async () => {
const agent = HttpAgent.createSync({
rootKey: new Uint8Array(31),
Expand All @@ -159,3 +159,79 @@ test('it should allow you to set an incorrect root key', async () => {

expect(actor.whoami).rejects.toThrowError(`Invalid certificate:`);
});

test('it should throw an error when the clock is out of sync during a query', async () => {
const canisterId = 'ivcos-eqaaa-aaaab-qablq-cai';
const idlFactory = () => {
return IDL.Service({
whoami: IDL.Func([], [IDL.Principal], ['query']),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as unknown as any;
};
vi.useRealTimers();

// set date to long ago
vi.spyOn(Date, 'now').mockImplementation(() => {
return new Date('2021-01-01T00:00:00Z').getTime();
});
// vi.setSystemTime(new Date('2021-01-01T00:00:00Z'));

const agent = await HttpAgent.create({ host: 'https://icp-api.io', fetch: globalThis.fetch });

const actor = Actor.createActor(idlFactory, {
agent,
canisterId,
});
try {
// should throw an error
await actor.whoami();
} catch (err) {
// handle the replica time error
if (err.name === 'ReplicaTimeError') {
const error = err as ReplicaTimeError;
// use the replica time to sync the agent
error.agent.replicaTime = error.replicaTime;
}
}
// retry the call
const result = await actor.whoami();
expect(Principal.from(result)).toBeInstanceOf(Principal);
});

test('it should throw an error when the clock is out of sync during an update', async () => {
const canisterId = 'ivcos-eqaaa-aaaab-qablq-cai';
const idlFactory = () => {
return IDL.Service({
whoami: IDL.Func([], [IDL.Principal], []),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as unknown as any;
};
vi.useRealTimers();

// set date to long ago
vi.spyOn(Date, 'now').mockImplementation(() => {
return new Date('2021-01-01T00:00:00Z').getTime();
});
// vi.setSystemTime(new Date('2021-01-01T00:00:00Z'));

const agent = await HttpAgent.create({ host: 'https://icp-api.io', fetch: globalThis.fetch });

const actor = Actor.createActor(idlFactory, {
agent,
canisterId,
});
try {
// should throw an error
await actor.whoami();
} catch (err) {
// handle the replica time error
if (err.name === 'ReplicaTimeError') {
const error = err as ReplicaTimeError;
// use the replica time to sync the agent
error.agent.replicaTime = error.replicaTime;
// retry the call
const result = await actor.whoami();
expect(Principal.from(result)).toBeInstanceOf(Principal);
}
}
});
9 changes: 8 additions & 1 deletion packages/agent/src/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Buffer } from 'buffer/';
import {
Agent,
getDefaultAgent,
HttpAgent,
HttpDetailsResponse,
QueryResponseRejected,
QueryResponseStatus,
Expand Down Expand Up @@ -535,13 +536,19 @@ function _createActorMethod(
});
let reply: ArrayBuffer | undefined;
let certificate: Certificate | undefined;
const certTime = (agent as HttpAgent).replicaTime
? (agent as HttpAgent).replicaTime
: undefined;

certTime;

if (response.body && response.body.certificate) {
const cert = response.body.certificate;
certificate = await Certificate.create({
certificate: bufFromBufLike(cert),
rootKey: agent.rootKey,
canisterId: Principal.from(canisterId),
blsVerify,
certTime,
});
const path = [new TextEncoder().encode('request_status'), requestId];
const status = new TextDecoder().decode(
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/agent/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export interface CallOptions {

export interface ReadStateResponse {
certificate: ArrayBuffer;
replicaTime?: Date;
}

export interface SubmitResponse {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`calculateReplicaTime 1`] = `2024-08-13T22:49:30.148Z`;
7 changes: 7 additions & 0 deletions packages/agent/src/agent/http/calculateReplicaTime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { calculateReplicaTime } from './calculateReplicaTime';
const exampleMessage = `Specified ingress_expiry not within expected range: Minimum allowed expiry: 2024-08-13 22:49:30.148075776 UTC, Maximum allowed expiry: 2024-08-13 22:55:00.148075776 UTC, Provided expiry: 2021-01-01 00:04:00 UTC`;

test('calculateReplicaTime', () => {
const parsedTime = calculateReplicaTime(exampleMessage);
expect(parsedTime).toMatchSnapshot();
});
22 changes: 22 additions & 0 deletions packages/agent/src/agent/http/calculateReplicaTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Parse the expiry from the message
* @param message an error message
* @returns diff in milliseconds
*/
export const calculateReplicaTime = (message: string): Date => {
const [min, max] = message.split('UTC');

const minsplit = min.trim().split(' ').reverse();

const minDateString = `${minsplit[1]} ${minsplit[0]} UTC`;

const maxsplit = max.trim().split(' ').reverse();

const maxDateString = `${maxsplit[1]} ${maxsplit[0]} UTC`;

return new Date(minDateString);
};

function midwayBetweenDates(date1: Date, date2: Date) {
return new Date((date1.getTime() + date2.getTime()) / 2);
}
19 changes: 18 additions & 1 deletion packages/agent/src/agent/http/errors.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
import { HttpAgent } from '.';
import { AgentError } from '../../errors';
import { HttpDetailsResponse } from '../api';

export class AgentHTTPResponseError extends AgentError {
constructor(message: string, public readonly response: HttpDetailsResponse) {
constructor(
message: string,
public readonly response: HttpDetailsResponse,
) {
super(message);
this.name = this.constructor.name;
Object.setPrototypeOf(this, new.target.prototype);
}
}

export class ReplicaTimeError extends AgentError {
public readonly replicaTime: Date;
public readonly agent: HttpAgent;

constructor(message: string, replicaTime: Date, agent: HttpAgent) {
super(message);
this.name = 'ReplicaTimeError';
this.replicaTime = replicaTime;
this.agent = agent;
Object.setPrototypeOf(this, new.target.prototype);
}
}
4 changes: 3 additions & 1 deletion packages/agent/src/agent/http/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import { JSDOM } from 'jsdom';
import { Actor, AnonymousIdentity, SignIdentity, toHex } from '../..';
import { Ed25519KeyIdentity } from '@dfinity/identity';
import { AgentError } from '../../errors';
import { AgentHTTPResponseError } from './errors';
import { AgentHTTPResponseError, ReplicaTimeError } from './errors';
import { IDL } from '@dfinity/candid';
const { window } = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`);
window.fetch = global.fetch;
(global as any).window = window;
Expand Down Expand Up @@ -813,3 +814,4 @@ test('it should log errors to console if the option is set', async () => {
await agent.syncTime();
});

jest.setTimeout(5000);
Loading

0 comments on commit a973a4d

Please sign in to comment.