From 02c54b09d0dc68effea9d0c88af8eca9c1655bd5 Mon Sep 17 00:00:00 2001 From: Petar Penovic Date: Sun, 1 Sep 2024 17:03:34 +0200 Subject: [PATCH] feat: increase rpc error information propagation --- __tests__/rpcChannel.test.ts | 27 ++++++++++- __tests__/utils/errors.test.ts | 22 +++++++++ scripts/generateRpcErrorMap.js | 24 ++++++++++ src/channel/rpc_0_6.ts | 9 ++-- src/channel/rpc_0_7.ts | 9 ++-- src/provider/index.ts | 2 +- src/provider/rpc.ts | 2 +- src/types/errors.ts | 33 ++++++++++++++ src/types/index.ts | 12 ++--- .../errors.ts => utils/errors/index.ts} | 45 ++++++++++++++----- src/utils/errors/rpc.ts | 32 +++++++++++++ www/docs/guides/connect_network.md | 14 ++++++ 12 files changed, 200 insertions(+), 31 deletions(-) create mode 100644 __tests__/utils/errors.test.ts create mode 100644 scripts/generateRpcErrorMap.js create mode 100644 src/types/errors.ts rename src/{provider/errors.ts => utils/errors/index.ts} (59%) create mode 100644 src/utils/errors/rpc.ts diff --git a/__tests__/rpcChannel.test.ts b/__tests__/rpcChannel.test.ts index f82ee7ab5..8d0a0d01a 100644 --- a/__tests__/rpcChannel.test.ts +++ b/__tests__/rpcChannel.test.ts @@ -1,4 +1,4 @@ -import { RPC07 } from '../src'; +import { LibraryError, RPC07, RpcError } from '../src'; import { createBlockForDevnet, getTestProvider } from './config/fixtures'; import { initializeMatcher } from './config/schema'; @@ -15,4 +15,29 @@ describe('RPC 0.7.0', () => { const response = await channel.getBlockWithReceipts('latest'); expect(response).toMatchSchemaRef('BlockWithTxReceipts'); }); + + test('RPC error handling', async () => { + const fetchSpy = jest.spyOn(channel, 'fetch'); + fetchSpy.mockResolvedValue({ + json: async () => ({ + jsonrpc: '2.0', + error: { + code: 24, + message: 'Block not found', + }, + id: 0, + }), + } as any); + + expect.assertions(3); + try { + // @ts-expect-error + await channel.fetchEndpoint('starknet_chainId'); + } catch (error) { + expect(error).toBeInstanceOf(LibraryError); + expect(error).toBeInstanceOf(RpcError); + expect((error as RpcError).isType('BLOCK_NOT_FOUND')).toBe(true); + } + fetchSpy.mockRestore(); + }); }); diff --git a/__tests__/utils/errors.test.ts b/__tests__/utils/errors.test.ts new file mode 100644 index 000000000..fd50aff21 --- /dev/null +++ b/__tests__/utils/errors.test.ts @@ -0,0 +1,22 @@ +import { RPC, RpcError } from '../../src'; + +describe('Error utility tests', () => { + test('RpcError', () => { + const baseError: RPC.Errors.UNEXPECTED_ERROR = { + code: 63, + message: 'An unexpected error occurred', + data: 'data', + }; + const method = 'GET'; + const error = new RpcError(baseError, method, method); + + expect(error.baseError).toBe(baseError); + expect(error.message).toMatch(/^RPC: \S+ with params \S+/); + expect(error.code).toEqual(baseError.code); + expect(error.request.method).toEqual(method); + expect(error.request.params).toEqual(method); + + expect(error.isType('BLOCK_NOT_FOUND')).toBe(false); + expect(error.isType('UNEXPECTED_ERROR')).toBe(true); + }); +}); diff --git a/scripts/generateRpcErrorMap.js b/scripts/generateRpcErrorMap.js new file mode 100644 index 000000000..0460d9d97 --- /dev/null +++ b/scripts/generateRpcErrorMap.js @@ -0,0 +1,24 @@ +// Processes the RPC specification error types and logs the output to simplify the generation +// of an error aggregating TS type and error code mapping object. Currently used in: +// - src/types/errors.ts +// - src/utils/errors/rpc.ts + +const starknet_api_openrpc = require('starknet_specs/api/starknet_api_openrpc.json'); +const starknet_trace_api_openrpc = require('starknet_specs/api/starknet_trace_api_openrpc.json'); +const starknet_write_api = require('starknet_specs/api/starknet_write_api.json'); + +const errorNameCodeMap = Object.fromEntries( + Object.entries({ + ...starknet_trace_api_openrpc.components.errors, + ...starknet_write_api.components.errors, + ...starknet_api_openrpc.components.errors, + }) + .map((e) => [e[0], e[1].code]) + .sort((a, b) => a[1] - b[1]) +); + +console.log('errorCodes:'); +console.log(errorNameCodeMap); +console.log(); +console.log('errorTypes:'); +Object.keys(errorNameCodeMap).forEach((n) => console.log(`${n}: Errors.${n};`)); diff --git a/src/channel/rpc_0_6.ts b/src/channel/rpc_0_6.ts index 1558f3b85..07bef1ed6 100644 --- a/src/channel/rpc_0_6.ts +++ b/src/channel/rpc_0_6.ts @@ -1,5 +1,5 @@ import { NetworkName, StarknetChainId } from '../constants'; -import { LibraryError } from '../provider/errors'; +import { LibraryError, RpcError } from '../utils/errors'; import { AccountInvocationItem, AccountInvocations, @@ -11,6 +11,7 @@ import { DeployAccountContractTransaction, Invocation, InvocationsDetailsWithNonce, + RPC_ERROR, RpcProviderOptions, TransactionType, getEstimateFeeBulkOptions, @@ -102,11 +103,7 @@ export class RpcChannel { protected errorHandler(method: string, params: any, rpcError?: JRPC.Error, otherError?: any) { if (rpcError) { - const { code, message, data } = rpcError; - throw new LibraryError( - `RPC: ${method} with params ${stringify(params, null, 2)}\n - ${code}: ${message}: ${stringify(data)}` - ); + throw new RpcError(rpcError as RPC_ERROR, method, params); } if (otherError instanceof LibraryError) { throw otherError; diff --git a/src/channel/rpc_0_7.ts b/src/channel/rpc_0_7.ts index cd14a8b77..1918fc880 100644 --- a/src/channel/rpc_0_7.ts +++ b/src/channel/rpc_0_7.ts @@ -1,5 +1,5 @@ import { NetworkName, StarknetChainId } from '../constants'; -import { LibraryError } from '../provider/errors'; +import { LibraryError, RpcError } from '../utils/errors'; import { AccountInvocationItem, AccountInvocations, @@ -11,6 +11,7 @@ import { DeployAccountContractTransaction, Invocation, InvocationsDetailsWithNonce, + RPC_ERROR, RpcProviderOptions, TransactionType, getEstimateFeeBulkOptions, @@ -118,11 +119,7 @@ export class RpcChannel { protected errorHandler(method: string, params: any, rpcError?: JRPC.Error, otherError?: any) { if (rpcError) { - const { code, message, data } = rpcError; - throw new LibraryError( - `RPC: ${method} with params ${stringify(params, null, 2)}\n - ${code}: ${message}: ${stringify(data)}` - ); + throw new RpcError(rpcError as RPC_ERROR, method, params); } if (otherError instanceof LibraryError) { throw otherError; diff --git a/src/provider/index.ts b/src/provider/index.ts index 0a1e83ad2..027a54085 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -1,7 +1,7 @@ import { RpcProvider } from './rpc'; export { RpcProvider as Provider } from './extensions/default'; // backward-compatibility -export * from './errors'; +export * from '../utils/errors'; export * from './interface'; export * from './extensions/default'; diff --git a/src/provider/rpc.ts b/src/provider/rpc.ts index 28ee0cdb5..74e49bc6a 100644 --- a/src/provider/rpc.ts +++ b/src/provider/rpc.ts @@ -44,7 +44,7 @@ import { RPCResponseParser } from '../utils/responseParser/rpc'; import { formatSignature } from '../utils/stark'; import { GetTransactionReceiptResponse, ReceiptTx } from '../utils/transactionReceipt'; import { getMessageHash, validateTypedData } from '../utils/typedData'; -import { LibraryError } from './errors'; +import { LibraryError } from '../utils/errors'; import { ProviderInterface } from './interface'; export class RpcProvider implements ProviderInterface { diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 000000000..d8ee90eb0 --- /dev/null +++ b/src/types/errors.ts @@ -0,0 +1,33 @@ +import { Errors } from 'starknet-types-07'; + +// NOTE: generated with scripts/generateRpcErrorMap.js +export type RPC_ERROR_SET = { + FAILED_TO_RECEIVE_TXN: Errors.FAILED_TO_RECEIVE_TXN; + NO_TRACE_AVAILABLE: Errors.NO_TRACE_AVAILABLE; + CONTRACT_NOT_FOUND: Errors.CONTRACT_NOT_FOUND; + BLOCK_NOT_FOUND: Errors.BLOCK_NOT_FOUND; + INVALID_TXN_INDEX: Errors.INVALID_TXN_INDEX; + CLASS_HASH_NOT_FOUND: Errors.CLASS_HASH_NOT_FOUND; + TXN_HASH_NOT_FOUND: Errors.TXN_HASH_NOT_FOUND; + PAGE_SIZE_TOO_BIG: Errors.PAGE_SIZE_TOO_BIG; + NO_BLOCKS: Errors.NO_BLOCKS; + INVALID_CONTINUATION_TOKEN: Errors.INVALID_CONTINUATION_TOKEN; + TOO_MANY_KEYS_IN_FILTER: Errors.TOO_MANY_KEYS_IN_FILTER; + CONTRACT_ERROR: Errors.CONTRACT_ERROR; + TRANSACTION_EXECUTION_ERROR: Errors.TRANSACTION_EXECUTION_ERROR; + CLASS_ALREADY_DECLARED: Errors.CLASS_ALREADY_DECLARED; + INVALID_TRANSACTION_NONCE: Errors.INVALID_TRANSACTION_NONCE; + INSUFFICIENT_MAX_FEE: Errors.INSUFFICIENT_MAX_FEE; + INSUFFICIENT_ACCOUNT_BALANCE: Errors.INSUFFICIENT_ACCOUNT_BALANCE; + VALIDATION_FAILURE: Errors.VALIDATION_FAILURE; + COMPILATION_FAILED: Errors.COMPILATION_FAILED; + CONTRACT_CLASS_SIZE_IS_TOO_LARGE: Errors.CONTRACT_CLASS_SIZE_IS_TOO_LARGE; + NON_ACCOUNT: Errors.NON_ACCOUNT; + DUPLICATE_TX: Errors.DUPLICATE_TX; + COMPILED_CLASS_HASH_MISMATCH: Errors.COMPILED_CLASS_HASH_MISMATCH; + UNSUPPORTED_TX_VERSION: Errors.UNSUPPORTED_TX_VERSION; + UNSUPPORTED_CONTRACT_CLASS_VERSION: Errors.UNSUPPORTED_CONTRACT_CLASS_VERSION; + UNEXPECTED_ERROR: Errors.UNEXPECTED_ERROR; +}; + +export type RPC_ERROR = RPC_ERROR_SET[keyof RPC_ERROR_SET]; diff --git a/src/types/index.ts b/src/types/index.ts index 08ef3e361..9c87191a0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,12 +1,14 @@ +export * from './lib'; +export * from './provider'; + export * from './account'; +export * from './cairoEnum'; export * from './calldata'; export * from './contract'; -export * from './lib'; -export * from './provider'; +export * from './errors'; +export * from './outsideExecution'; export * from './signer'; -export * from './typedData'; -export * from './cairoEnum'; export * from './transactionReceipt'; -export * from './outsideExecution'; +export * from './typedData'; export * as RPC from './api'; diff --git a/src/provider/errors.ts b/src/utils/errors/index.ts similarity index 59% rename from src/provider/errors.ts rename to src/utils/errors/index.ts index 1e2a01b4b..2207a6049 100644 --- a/src/provider/errors.ts +++ b/src/utils/errors/index.ts @@ -1,3 +1,8 @@ +/* eslint-disable max-classes-per-file */ +import { RPC, RPC_ERROR, RPC_ERROR_SET } from '../../types'; +import { stringify } from '../json'; +import rpcErrors from './rpc'; + // eslint-disable-next-line max-classes-per-file export function fixStack(target: Error, fn: Function = target.constructor) { const { captureStackTrace } = Error as any; @@ -36,20 +41,38 @@ export class CustomError extends Error { export class LibraryError extends CustomError {} -export class GatewayError extends LibraryError { +export class RpcError extends LibraryError { + public readonly request: { + method: string; + params: any; + }; + constructor( - message: string, - public errorCode: string + public readonly baseError: BaseErrorT, + method: string, + params: any ) { - super(message); + // legacy message format + super(`RPC: ${method} with params ${stringify(params, null, 2)}\n + ${baseError.code}: ${baseError.message}: ${stringify((baseError as RPC.JRPC.Error).data)}`); + + this.request = { method, params }; } -} -export class HttpError extends LibraryError { - constructor( - message: string, - public errorCode: number - ) { - super(message); + public get code() { + return this.baseError.code; + } + + /** + * Verifies the underlying RPC error, also serves as a type guard for the _baseError_ property + * @example + * ```typescript + * SomeError.isType('UNEXPECTED_ERROR'); + * ``` + */ + public isType( + typeName: N + ): this is RpcError { + return rpcErrors[typeName] === this.code; } } diff --git a/src/utils/errors/rpc.ts b/src/utils/errors/rpc.ts new file mode 100644 index 000000000..fc174034c --- /dev/null +++ b/src/utils/errors/rpc.ts @@ -0,0 +1,32 @@ +import { RPC_ERROR_SET } from '../../types'; + +// NOTE: generated with scripts/generateRpcErrorMap.js +const errorCodes: { [K in keyof RPC_ERROR_SET]: RPC_ERROR_SET[K]['code'] } = { + FAILED_TO_RECEIVE_TXN: 1, + NO_TRACE_AVAILABLE: 10, + CONTRACT_NOT_FOUND: 20, + BLOCK_NOT_FOUND: 24, + INVALID_TXN_INDEX: 27, + CLASS_HASH_NOT_FOUND: 28, + TXN_HASH_NOT_FOUND: 29, + PAGE_SIZE_TOO_BIG: 31, + NO_BLOCKS: 32, + INVALID_CONTINUATION_TOKEN: 33, + TOO_MANY_KEYS_IN_FILTER: 34, + CONTRACT_ERROR: 40, + TRANSACTION_EXECUTION_ERROR: 41, + CLASS_ALREADY_DECLARED: 51, + INVALID_TRANSACTION_NONCE: 52, + INSUFFICIENT_MAX_FEE: 53, + INSUFFICIENT_ACCOUNT_BALANCE: 54, + VALIDATION_FAILURE: 55, + COMPILATION_FAILED: 56, + CONTRACT_CLASS_SIZE_IS_TOO_LARGE: 57, + NON_ACCOUNT: 58, + DUPLICATE_TX: 59, + COMPILED_CLASS_HASH_MISMATCH: 60, + UNSUPPORTED_TX_VERSION: 61, + UNSUPPORTED_CONTRACT_CLASS_VERSION: 62, + UNEXPECTED_ERROR: 63, +}; +export default errorCodes; diff --git a/www/docs/guides/connect_network.md b/www/docs/guides/connect_network.md index 0a097d905..48bdc9c97 100644 --- a/www/docs/guides/connect_network.md +++ b/www/docs/guides/connect_network.md @@ -200,3 +200,17 @@ const [getBlockResponse, blockHashAndNumber, txCount] = await Promise.all([ // ... usage of getBlockResponse, blockHashAndNumber, txCount ``` + +## Error handling + +The [Starknet RPC specification](https://github.com/starkware-libs/starknet-specs) defines a set of possible errors that the RPC endpoints could return for various scenarios. If such errors arise `starknet.js` represents them with the corresponding [RpcError](../API/classes/RpcError) class where the endpoint error response information is contained within the `baseError` property. Also of note is that the class has an `isType` convenience method that verifies the base error type as shown in the example below. + +#### Example + +```typescript +try { + ... +} catch (error) { + if (error instanceof RpcError && error.isType('UNEXPECTED_ERROR')) { ... } +} +```