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

Implement SDK error handling #1014

Merged
merged 20 commits into from
Feb 20, 2020
Merged
Show file tree
Hide file tree
Changes from 2 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: 1 addition & 1 deletion raiden-ts/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as t from 'io-ts';
import { ShutdownReason } from './constants';
import { PartialRaidenConfig } from './config';
import { ActionType, createAction, Action } from './utils/actions';
import { ErrorCodec } from './utils/types';
import { ErrorCodec } from './utils/error';

import * as ChannelsActions from './channels/actions';
import * as TransportActions from './transport/actions';
Expand Down
41 changes: 28 additions & 13 deletions raiden-ts/src/path/epics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ import { Address, decode, Int, Signature, Signed, UInt } from '../utils/types';
import { isActionOf } from '../utils/actions';
import { encode, losslessParse, losslessStringify } from '../utils/data';
import { getEventsStream } from '../utils/ethers';
import { PfsError } from '../utils/error';
import { iouClear, pathFind, iouPersist, pfsListUpdated } from './actions';
import { channelCanRoute, pfsInfo, pfsListInfo } from './utils';
import { IOU, LastIOUResults, PathResults, Paths, PFS } from './types';
import { PfsErrorCodes } from './errors';

const oneToNAddress = memoize(
async (userDepositContract: UserDeposit) =>
Expand Down Expand Up @@ -148,16 +150,21 @@ const prepareNextIOU$ = (
}
const text = await response.text();
if (!response.ok)
throw new Error(
`PFS: last IOU request: code=${response.status} => body="${text}"`,
);
throw new PfsError(PfsErrorCodes.PFS_LAST_IOU_REQUEST_FAILED, [
{ key: 'responseStatus', value: response.status },
{ key: 'responseText', value: text },
]);

const { last_iou: lastIou } = decode(LastIOUResults, losslessParse(text));
const signer = verifyMessage(packIOU(lastIou), lastIou.signature);
if (signer !== deps.address)
throw new Error(
`PFS: last iou signature mismatch: signer=${signer} instead of us ${deps.address}`,
);
throw new PfsError(PfsErrorCodes.PFS_IOU_SIGNATURE_MISMATCH, [
{
key: 'signer',
value: signer,
},
{ key: 'address', value: deps.address },
]);
return lastIou;
}),
)
Expand Down Expand Up @@ -201,9 +208,16 @@ export const pathFindServiceEpic = (
mergeMap(([state, presences, { pfs: configPfs, httpTimeout, pfsSafetyMargin }]) => {
const { tokenNetwork, target } = action.meta;
if (!(tokenNetwork in state.channels))
throw new Error(`PFS: unknown tokenNetwork ${tokenNetwork}`);
throw new PfsError(PfsErrorCodes.PFS_UNKNOWN_TOKEN_NETWORK, [
{ key: 'tokenNetwork', value: tokenNetwork },
]);
if (!(target in presences) || !presences[target].payload.available)
throw new Error(`PFS: target ${target} not online`);
throw new PfsError(PfsErrorCodes.PFS_TARGET_OFFLINE, [
{
key: 'target',
value: target,
},
]);

// if pathFind received a set of paths, pass it through to validation/cleanup
if (action.payload.paths) return of({ paths: action.payload.paths, iou: undefined });
Expand All @@ -220,7 +234,7 @@ export const pathFindServiceEpic = (
(!action.payload.pfs && configPfs === null) // disabled in config and not provided
) {
// pfs not specified in action and disabled (null) in config
throw new Error(`PFS disabled and no direct route available`);
throw new PfsError(PfsErrorCodes.PFS_DISABLED);
} else {
// else, request a route from PFS.
// pfs$ - Observable which emits one PFS info and then completes
Expand Down Expand Up @@ -328,9 +342,10 @@ export const pathFindServiceEpic = (
}
// if error, don't proceed
if (!data.paths) {
throw new Error(
`PFS: paths request: code=${data.error.error_code} => errors="${data.error.errors}"`,
);
throw new PfsError(PfsErrorCodes.PFS_ERROR_RESPONSE, [
{ key: 'errorCode', value: data.error.error_code },
{ key: 'errors', value: data.error.errors },
]);
}
const filteredPaths: Paths = [],
invalidatedRecipients = new Set<Address>();
Expand Down Expand Up @@ -367,7 +382,7 @@ export const pathFindServiceEpic = (
}
filteredPaths.push({ path, fee });
}
if (!filteredPaths.length) throw new Error(`PFS: no valid routes found`);
if (!filteredPaths.length) throw new PfsError(PfsErrorCodes.PFS_NO_ROUTES_FOUND);
yield pathFind.success({ paths: filteredPaths }, action.meta);
})(),
),
Expand Down
12 changes: 12 additions & 0 deletions raiden-ts/src/path/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export enum PfsErrorCodes {
PFS_EMPTY_URL = 'A registered Pathfinding Service returned an empty service URL.',
PFS_INVALID_URL = 'A registered Pathfinding Service returned an invalid service URL.',
PFS_INVALID_INFO = 'Could not any valid Pathfinding services. Client and PFS versions are possibly out-of-sync.',
PFS_NO_ROUTES_FOUND = 'No valid routes found.',
PFS_ERROR_RESPONSE = 'Pathfinding Service request returned an error',
PFS_DISABLED = 'Pathfinding Service is disabled and no direct route is available.',
PFS_UNKNOWN_TOKEN_NETWORK = 'Unknown token network.',
PFS_TARGET_OFFLINE = 'The requested target is offline.',
PFS_LAST_IOU_REQUEST_FAILED = 'The request for the last IOU has failed.',
PFS_IOU_SIGNATURE_MISMATCH = 'The signature of the last IOU did not match.',
}
12 changes: 6 additions & 6 deletions raiden-ts/src/path/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { Presences } from '../transport/types';
import { ChannelState } from '../channels/state';
import { channelAmounts } from '../channels/utils';
import { ServiceRegistry } from '../contracts/ServiceRegistry';
import { PfsError } from '../utils/error';
import { PFS } from './types';
import { PfsErrorCodes } from './errors';

/**
* Either returns true if given channel can route a payment, or a reason as string if not
Expand Down Expand Up @@ -86,8 +88,9 @@ export function pfsInfo(
return url$.pipe(
withLatestFrom(config$),
mergeMap(([url, { httpTimeout }]) => {
if (!url) throw new Error(`Empty URL: ${url}`);
else if (!urlRegex.test(url)) throw new Error(`Invalid URL: ${url}`);
if (!url) throw new PfsError(PfsErrorCodes.PFS_EMPTY_URL);
else if (!urlRegex.test(url))
throw new PfsError(PfsErrorCodes.PFS_EMPTY_URL, [{ key: 'url', value: url }]);
// default to https for domain-only urls
else if (!url.startsWith('https://')) url = `https://${url}`;

Expand Down Expand Up @@ -141,10 +144,7 @@ export function pfsListInfo(
),
toArray(),
map(list => {
if (!list.length)
throw new Error(
'Could not validate any PFS info. Possibly out-of-sync with PFSs version.',
);
if (!list.length) throw new PfsError(PfsErrorCodes.PFS_INVALID_INFO);
return list.sort((a, b) => {
const dif = a.price.sub(b.price);
// first, sort by price
Expand Down
3 changes: 2 additions & 1 deletion raiden-ts/src/utils/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { Observable } from 'rxjs';
import { first, map } from 'rxjs/operators';
import { Reducer } from 'redux';

import { ErrorCodec, BigNumberC, assert } from './types';
import { ErrorCodec } from './error';
import { BigNumberC, assert } from './types';

/**
* The type of a generic action
Expand Down
88 changes: 88 additions & 0 deletions raiden-ts/src/utils/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import * as t from 'io-ts';
import { map } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';

import { PfsErrorCodes } from '../path/errors';

export const ErrorDetails = t.array(
nephix marked this conversation as resolved.
Show resolved Hide resolved
t.type({
key: t.string,
value: t.union([t.string, t.number]),
}),
);
export interface ErrorDetails extends t.TypeOf<typeof ErrorDetails> {}

export default class RaidenError extends Error {
code: string;
details?: ErrorDetails;

constructor(message: string, details?: ErrorDetails) {
super(message || 'General Error');
this.name = 'RaidenError';
this.code = this.getCode(message);
this.details = details;
}

getCode(message: string): string {
return message ?? 'RAIDEN_ERROR';
}
}

export class PfsError extends RaidenError {
constructor(message: PfsErrorCodes, details?: ErrorDetails) {
super(message, details);
this.name = 'PfsError';
}

getCode(message: string): string {
andrevmatos marked this conversation as resolved.
Show resolved Hide resolved
return (
Object.keys(PfsErrorCodes).find(code => Object(PfsErrorCodes)[code] === message) ??
'PFS_GENERAL_ERROR'
);
}
}

const serializedErr = t.intersection([
t.type({ name: t.string, message: t.string, code: t.string }),
t.partial({ stack: t.string, details: ErrorDetails }),
]);

/**
* Simple Error codec
*
* This codec doesn't decode to an instance of the exact same error class object, but instead to
* a generic Error, but assigning 'name', 'stack' & 'message' properties, more as an informative
* object.
*/
export const ErrorCodec = new t.Type<
RaidenError,
{ name: string; message: string; code: string; stack?: string; details?: ErrorDetails }
>(
'RaidenError',
(u: unknown): u is RaidenError => u instanceof RaidenError,
u => {
if (u instanceof RaidenError) return t.success(u);
return pipe(
serializedErr.decode(u),
map(({ name, message, code, stack, details }) => {
switch (name) {
case 'PfsError':
return Object.assign(new PfsError(message as PfsErrorCodes, details), {
name,
stack,
code,
});
}

return Object.assign(new RaidenError(message), { name, stack });
}),
);
},
({ name, message, stack, details, code }) => ({
name,
message,
stack,
code,
details: details ?? undefined,
}),
);
28 changes: 1 addition & 27 deletions raiden-ts/src/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import * as t from 'io-ts';
import { BigNumber, bigNumberify, getAddress, isHexString, hexDataLength } from 'ethers/utils';
import { Two, Zero } from 'ethers/constants';
import { memoize } from 'lodash';
import { Either, Right, map } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import { Either, Right } from 'fp-ts/lib/Either';
import { ThrowReporter } from 'io-ts/lib/ThrowReporter';

/* A Subset of DOM's Storage/localStorage interface which supports async/await */
Expand Down Expand Up @@ -59,31 +58,6 @@ export function isntNil<T>(value: T): value is NonNullable<T> {
return value != null;
}

const serializedErr = t.intersection([
t.type({ name: t.string, message: t.string }),
t.partial({ stack: t.string }),
]);

/**
* Simple Error codec
*
* This codec doesn't decode to an instance of the exact same error class object, but instead to
* a generic Error, but assigning 'name', 'stack' & 'message' properties, more as an informative
* object.
*/
export const ErrorCodec = new t.Type<Error, { name: string; message: string; stack?: string }>(
'Error',
(u: unknown): u is Error => u instanceof Error,
u => {
if (u instanceof Error) return t.success(u);
return pipe(
serializedErr.decode(u),
map(({ name, message, stack }) => Object.assign(new Error(message), { name, stack })),
);
},
e => ({ name: e.name, message: e.message, stack: e.stack }),
);

/**
* Codec of ethers.utils.BigNumber objects
*
Expand Down
78 changes: 78 additions & 0 deletions raiden-ts/tests/unit/error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { isError } from 'util';

import RaidenError, { ErrorDetails } from '../../src/utils/error';

enum MyCustomErrorCodes {
TEST = 'Developer-friendly description',
}

class MyCustomError extends RaidenError {
constructor(message: MyCustomErrorCodes, detail?: ErrorDetails) {
super(message, detail);
this.name = 'MyCustomError';
}

getCode(message: string): string {
return (
Object.keys(MyCustomErrorCodes).find(code => Object(MyCustomErrorCodes)[code] === message) ??
'GENERAL_ERROR'
);
}
}

describe('Test custom error', () => {
test('MyCustomError is instance of its custom class', () => {
try {
throw new MyCustomError(MyCustomErrorCodes.TEST);
} catch (err) {
expect(err).toBeInstanceOf(MyCustomError);
expect(err.name).toEqual('MyCustomError');
}
});

test('MyCustomError is an instance of Error', () => {
try {
throw new MyCustomError(MyCustomErrorCodes.TEST);
} catch (err) {
expect(err instanceof Error).toBeTruthy();
expect(isError(err)).toBeTruthy();
}
});

test('Has stack trace w/ class name and developer-friendly message', () => {
try {
function doSomething() {
throw new MyCustomError(MyCustomErrorCodes.TEST);
}
doSomething();
} catch (err) {
// Stack trace exists
expect(err.stack).toBeDefined();

// Stack trace starts with the error message
expect(err.stack.split('\n').shift()).toEqual(
'MyCustomError: Developer-friendly description',
);

// Stack trace contains function where error was thrown
expect(err.stack.split('\n')[1]).toContain('doSomething');
}
});

test('End user "code" property is set', () => {
try {
throw new MyCustomError(MyCustomErrorCodes.TEST);
} catch (err) {
expect(err.code).toBeDefined();
expect(err.code).toEqual('TEST');
}
});

test('Details can be added and are shown in stack trace', () => {
try {
throw new MyCustomError(MyCustomErrorCodes.TEST, [{ value: 'bar', key: 'foo' }]);
} catch (err) {
expect(err.details).toEqual([{ value: 'bar', key: 'foo' }]);
}
});
});