diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f0973d4c..86ce5517 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/packages/agent/src/actor.test.ts b/packages/agent/src/actor.test.ts index d15483d6..8a87b524 100644 --- a/packages/agent/src/actor.test.ts +++ b/packages/agent/src/actor.test.ts @@ -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'); @@ -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]), diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index bf4f906c..27833dd3 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -153,6 +153,7 @@ export type ActorSubclass> = Actor & T; */ export interface ActorMethod { (...args: Args): Promise; + withOptions(options: CallConfig): (...args: Args) => Promise; } diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index 22346eb6..c8f5b657 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -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; } } diff --git a/packages/agent/src/errors.test.ts b/packages/agent/src/errors.test.ts new file mode 100644 index 00000000..5e4fe2d5 --- /dev/null +++ b/packages/agent/src/errors.test.ts @@ -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); +}); diff --git a/packages/agent/src/errors.ts b/packages/agent/src/errors.ts index d2e759f9..deeab1ab 100644 --- a/packages/agent/src/errors.ts +++ b/packages/agent/src/errors.ts @@ -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, + ) { + 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); + } +} diff --git a/packages/agent/src/observable.test.ts b/packages/agent/src/observable.test.ts index 116ef9bc..70a85109 100644 --- a/packages/agent/src/observable.test.ts +++ b/packages/agent/src/observable.test.ts @@ -1,3 +1,4 @@ +import { AgentError } from './errors'; import { Observable, ObservableLog } from './observable'; describe('Observable', () => { @@ -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 });