Skip to content

Commit

Permalink
feat: improved assertion options for agent errors (#908)
Browse files Browse the repository at this point in the history
* feat: improved assertion options for agent errors

* fixes observable test

* chore: cast to v2ResponseBody

* chore: build fix

* chore: prettier

---------

Co-authored-by: Jason I <jason.ibrahim@dfinity.org>
  • Loading branch information
krpeacock and Jason I authored Oct 23, 2024
1 parent 7c147b8 commit b76cebc
Show file tree
Hide file tree
Showing 7 changed files with 180 additions and 5 deletions.
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

0 comments on commit b76cebc

Please sign in to comment.