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: improved assertion options for agent errors #908

Merged
merged 7 commits into from
Oct 23, 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
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

- feat: allow for setting HttpAgent ingress expiry using `ingressExpiryInMinutes` option

- feat: improved assertion options for agent errors using `prototype`, `name`, and `instanceof`

### Changed

- test: automatically deploys trap canister if it doesn't exist yet during e2e
Expand Down
5 changes: 3 additions & 2 deletions packages/agent/src/actor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { CallRequest, SubmitRequestType, UnSigned } from './agent/http/types';
import * as cbor from './cbor';
import { requestIdOf } from './request_id';
import * as pollingImport from './polling';
import { Actor, ActorConfig } from './actor';
import { ActorConfig } from './actor';
import { UpdateCallRejectedError } from './errors';

const importActor = async (mockUpdatePolling?: () => void) => {
jest.dontMock('./polling');
Expand All @@ -27,7 +28,7 @@ afterEach(() => {
describe('makeActor', () => {
// TODO: update tests to be compatible with changes to Certificate
it.skip('should encode calls', async () => {
const { Actor, UpdateCallRejectedError } = await importActor();
const { Actor } = await importActor();
const actorInterface = () => {
return IDL.Service({
greet: IDL.Func([IDL.Text], [IDL.Text]),
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export type ActorSubclass<T = Record<string, ActorMethod>> = Actor & T;
*/
export interface ActorMethod<Args extends unknown[] = unknown[], Ret = unknown> {
(...args: Args): Promise<Ret>;

withOptions(options: CallConfig): (...args: Args) => Promise<Ret>;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/agent/src/agent/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@ export class HttpAgent implements Agent {
);
}

this.log.error('Error while making call:', error as Error);
this.log.error('Error while making call:', error as AgentError);
throw error;
}
}
Expand Down
88 changes: 88 additions & 0 deletions packages/agent/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/* eslint-disable no-prototype-builtins */
import { QueryResponseStatus, SubmitResponse } from './agent';
import {
ActorCallError,
AgentError,
QueryCallRejectedError,
UpdateCallRejectedError,
} from './errors';
import { RequestId } from './request_id';

test('AgentError', () => {
const error = new AgentError('message');
expect(error.message).toBe('message');
expect(error.name).toBe('AgentError');
expect(error instanceof Error).toBe(true);
expect(error instanceof AgentError).toBe(true);
expect(error instanceof ActorCallError).toBe(false);
expect(AgentError.prototype.isPrototypeOf(error)).toBe(true);
});

test('ActorCallError', () => {
const error = new ActorCallError('rrkah-fqaaa-aaaaa-aaaaq-cai', 'methodName', 'query', {
props: 'props',
});
expect(error.message).toBe(`Call failed:
Canister: rrkah-fqaaa-aaaaa-aaaaq-cai
Method: methodName (query)
"props": "props"`);
expect(error.name).toBe('ActorCallError');
expect(error instanceof Error).toBe(true);
expect(error instanceof AgentError).toBe(true);
expect(error instanceof ActorCallError).toBe(true);
expect(ActorCallError.prototype.isPrototypeOf(error)).toBe(true);
});

test('QueryCallRejectedError', () => {
const error = new QueryCallRejectedError('rrkah-fqaaa-aaaaa-aaaaq-cai', 'methodName', {
status: QueryResponseStatus.Rejected,
reject_code: 1,
reject_message: 'reject_message',
error_code: 'error_code',
});
expect(error.message).toBe(`Call failed:
Canister: rrkah-fqaaa-aaaaa-aaaaq-cai
Method: methodName (query)
"Status": "rejected"
"Code": "SysFatal"
"Message": "reject_message"`);
expect(error.name).toBe('QueryCallRejectedError');
expect(error instanceof Error).toBe(true);
expect(error instanceof AgentError).toBe(true);
expect(error instanceof ActorCallError).toBe(true);
expect(error instanceof QueryCallRejectedError).toBe(true);
expect(QueryCallRejectedError.prototype.isPrototypeOf(error)).toBe(true);
});

test('UpdateCallRejectedError', () => {
const response: SubmitResponse['response'] = {
ok: false,
status: 400,
statusText: 'rejected',
body: {
error_code: 'error_code',
reject_code: 1,
reject_message: 'reject_message',
},
headers: [],
};
const error = new UpdateCallRejectedError(
'rrkah-fqaaa-aaaaa-aaaaq-cai',
'methodName',
new ArrayBuffer(1) as RequestId,
response,
);
expect(error.message).toBe(`Call failed:
Canister: rrkah-fqaaa-aaaaa-aaaaq-cai
Method: methodName (update)
"Request ID": "00"
"Error code": "error_code"
"Reject code": "1"
"Reject message": "reject_message"`);
expect(error.name).toBe('UpdateCallRejectedError');
expect(error instanceof Error).toBe(true);
expect(error instanceof AgentError).toBe(true);
expect(error instanceof ActorCallError).toBe(true);
expect(error instanceof UpdateCallRejectedError).toBe(true);
expect(UpdateCallRejectedError.prototype.isPrototypeOf(error)).toBe(true);
});
84 changes: 83 additions & 1 deletion packages/agent/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,94 @@
import { Principal } from '@dfinity/principal';
import {
QueryResponseRejected,
ReplicaRejectCode,
SubmitResponse,
v2ResponseBody,
} from './agent/api';
import { RequestId } from './request_id';
import { toHex } from './utils/buffer';

/**
* 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).
*
* @todo https://github.com/dfinity/agent-js/issues/420
*/
export class AgentError extends Error {
public name = 'AgentError';
public __proto__ = AgentError.prototype;
constructor(public readonly message: string) {
super(message);
Object.setPrototypeOf(this, AgentError.prototype);
}
}

export class ActorCallError extends AgentError {
public name = 'ActorCallError';
public __proto__ = ActorCallError.prototype;
constructor(
public readonly canisterId: Principal | string,
public readonly methodName: string,
public readonly type: 'query' | 'update',
public readonly props: Record<string, string>,
) {
const cid = Principal.from(canisterId);
super(
[
`Call failed:`,
` Canister: ${cid.toText()}`,
` Method: ${methodName} (${type})`,
...Object.getOwnPropertyNames(props).map(n => ` "${n}": ${JSON.stringify(props[n])}`),
].join('\n'),
);
Object.setPrototypeOf(this, ActorCallError.prototype);
}
}

export class QueryCallRejectedError extends ActorCallError {
public name = 'QueryCallRejectedError';
public __proto__ = QueryCallRejectedError.prototype;
constructor(
canisterId: Principal | string,
methodName: string,
public readonly result: QueryResponseRejected,
) {
const cid = Principal.from(canisterId);
super(cid, methodName, 'query', {
Status: result.status,
Code: ReplicaRejectCode[result.reject_code] ?? `Unknown Code "${result.reject_code}"`,
Message: result.reject_message,
});
Object.setPrototypeOf(this, QueryCallRejectedError.prototype);
}
}

export class UpdateCallRejectedError extends ActorCallError {
public name = 'UpdateCallRejectedError';
public __proto__ = UpdateCallRejectedError.prototype;
constructor(
canisterId: Principal | string,
methodName: string,
public readonly requestId: RequestId,
public readonly response: SubmitResponse['response'],
) {
const cid = Principal.from(canisterId);
super(cid, methodName, 'update', {
'Request ID': toHex(requestId),
...(response.body
? {
...((response.body as v2ResponseBody).error_code
? {
'Error code': (response.body as v2ResponseBody).error_code,
}
: {}),
'Reject code': String((response.body as v2ResponseBody).reject_code),
'Reject message': (response.body as v2ResponseBody).reject_message,
}
: {
'HTTP status code': response.status.toString(),
'HTTP status text': response.statusText,
}),
});
Object.setPrototypeOf(this, UpdateCallRejectedError.prototype);
}
}
3 changes: 2 additions & 1 deletion packages/agent/src/observable.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AgentError } from './errors';
import { Observable, ObservableLog } from './observable';

describe('Observable', () => {
Expand Down Expand Up @@ -48,7 +49,7 @@ describe('ObservableLog', () => {
observable.warn('warning');
expect(observer1).toHaveBeenCalledWith({ message: 'warning', level: 'warn' });
expect(observer2).toHaveBeenCalledWith({ message: 'warning', level: 'warn' });
const error = new Error('error');
const error = new AgentError('error');
observable.error('error', error);
expect(observer1).toHaveBeenCalledWith({ message: 'error', level: 'error', error });
expect(observer2).toHaveBeenCalledWith({ message: 'error', level: 'error', error });
Expand Down
Loading