Skip to content

Commit

Permalink
fix: 🐛 ethlogger crashes when certain rpc methods are disabled
Browse files Browse the repository at this point in the history
Added check to see if certain RPC methods (hashRate and peerCount) are
available before using them. This fixes compatibility with alchemy as
they do not provide those methods.
  • Loading branch information
ziegfried committed Feb 10, 2020
1 parent 80bb0da commit ee56c01
Show file tree
Hide file tree
Showing 4 changed files with 86 additions and 30 deletions.
3 changes: 2 additions & 1 deletion src/eth/client.ts
Expand Up @@ -45,7 +45,8 @@ export async function executeBatchRequest(batch: BatchReq[], transport: Ethereum
export class EthereumClient {
constructor(public readonly transport: EthereumTransport) {}

async request<P extends any[], R>(req: EthRequest<P, R>): Promise<R> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async request<P extends any[], R>(req: EthRequest<P, R>, options?: { immediate?: boolean }): Promise<R> {
const payload = createJsonRpcPayload(req.method, req.params);
const res = await this.transport.send(payload);
if (payload.id !== res.id) {
Expand Down
44 changes: 32 additions & 12 deletions src/eth/http.ts
@@ -1,14 +1,20 @@
import { default as HttpAgent, HttpOptions, HttpsAgent } from 'agentkeepalive';
import fetch from 'node-fetch';
import { HttpTransportConfig } from '../config';
import { createModuleDebug } from '../utils/debug';
import { isHttps } from '../utils/httputils';
import { isValidJsonRpcResponse, JsonRpcRequest, JsonRpcResponse, checkError } from './jsonrpc';
import { AggregateMetric, httpClientStats } from '../utils/stats';
import { checkError, JsonRpcRequest, JsonRpcResponse, validateJsonRpcResponse } from './jsonrpc';
import { EthereumTransport } from './transport';
import { httpClientStats, AggregateMetric } from '../utils/stats';
import { HttpTransportConfig } from '../config';

const { debug, trace } = createModuleDebug('eth:http');

export class HttpTransportError extends Error {
constructor(message: string, public response?: string | any) {
super(message);
}
}

const CONFIG_DEFAULTS = {
timeout: 60_000,
validateCertificate: true,
Expand Down Expand Up @@ -54,7 +60,7 @@ export class HttpTransport implements EthereumTransport {
debug('Sending JSON RPC request: %o', request.method);
const result = await this.sendInternal(request);
if (Array.isArray(result)) {
throw new Error(`JSON RPC returned batch but expected single message`);
throw new HttpTransportError(`JSON RPC returned batch but expected single message`, result);
}
return result;
}
Expand All @@ -63,7 +69,7 @@ export class HttpTransport implements EthereumTransport {
debug('Sending JSON RPC batch containing %d requests', request.length);
const result = await this.sendInternal(request);
if (!Array.isArray(result)) {
throw new Error(`JSON RPC returned single message, was expecting batch`);
throw new HttpTransportError(`JSON RPC returned single message, was expecting batch`, result);
}
return result;
}
Expand All @@ -87,25 +93,39 @@ export class HttpTransport implements EthereumTransport {
agent: this.httpAgent,
timeout: this.config.timeout,
});
if (response.status < 200 || response.status > 299) {
throw new Error(
`JSON RPC service ${this.url} responded with HTTP status ${response.status} (${response.statusText})`
if (!response.ok) {
let responseBody: any = null;
try {
responseBody = await response.text();
try {
responseBody = JSON.parse(responseBody);
} catch (e) {
// ignore
}
} catch (e) {
// ignore
}

throw new HttpTransportError(
`JSON RPC service ${this.url} responded with HTTP status ${response.status} (${response.statusText})`,
responseBody
);
}
const data = await response.json();
trace('Received JSON RPC response:\n%O', data);
if (!isValidJsonRpcResponse(data)) {
throw new Error('Invalid JSON RPC response');
if (!validateJsonRpcResponse(data)) {
throw new HttpTransportError('UNREACHABLE: Invalid JSON RPC response', data);
}
this.aggregates.requestDuration.push(Date.now() - startTime);
debug('Completed JSON RPC request in %d ms', Date.now() - startTime);

if (Array.isArray(request) !== Array.isArray(data)) {
checkError(Array.isArray(data) ? data[0] : data);
throw new Error(
throw new HttpTransportError(
Array.isArray(request)
? 'JSON RPC returned single message, was expecting batch'
: 'JSON RPC returned batch, was expecting single message'
: 'JSON RPC returned batch, was expecting single message',
data
);
}

Expand Down
47 changes: 33 additions & 14 deletions src/eth/jsonrpc.ts
Expand Up @@ -2,6 +2,18 @@ let msgId: number = 0;

const nextMsgId = (): number => ++msgId;

export class JsonRpcError extends Error {
constructor(message: string, public readonly code?: number, public readonly data?: any) {
super(message);
}
}

export class InvalidJsonRpcResponseError extends JsonRpcError {
constructor(message: string, data?: any) {
super(`Invalid JSON RPC response: ${message}`, undefined, data);
}
}

export interface JsonRpcRequest {
jsonrpc: string;
method: string;
Expand Down Expand Up @@ -29,24 +41,31 @@ export function createJsonRpcPayload(method: string, params?: any[]): JsonRpcReq
};
}

const validateSingleMessage = (message: any): message is JsonRpcResponse =>
message != null &&
message.jsonrpc === '2.0' &&
(typeof message.id === 'number' || typeof message.id === 'string') &&
(message.result != null || message.error != null);

export class JsonRpcError extends Error {
constructor(message: string, public readonly code?: number, public readonly data?: any) {
super(message);
}
}

export function checkError(msg?: JsonRpcResponse) {
if (msg?.error) {
throw new JsonRpcError(msg.error.message, msg.error.code, msg.error.data);
}
if (msg?.result == null) {
throw new JsonRpcError('No result');
}
}

export function isValidJsonRpcResponse(response: any): response is JsonRpcResponse | JsonRpcResponse[] {
return Array.isArray(response) ? response.every(validateSingleMessage) : validateSingleMessage(response);
export function validateJsonRpcResponse(response: any): response is JsonRpcResponse | JsonRpcResponse[] {
if (response == null) {
throw new InvalidJsonRpcResponseError('Response message is null/empty');
}

if (Array.isArray(response)) {
return response.every(validateJsonRpcResponse);
}

if (response.jsonrpc !== '2.0') {
throw new InvalidJsonRpcResponseError(`Invalid jsonrpc value (${response.jsonrpc}) in message`);
}

if (!(typeof response.id === 'number' || typeof response.id === 'string')) {
throw new InvalidJsonRpcResponseError(`Invalid message ID (type: ${typeof response.id}) in message`);
}

return true;
}
22 changes: 19 additions & 3 deletions src/platforms/generic.ts
Expand Up @@ -22,11 +22,27 @@ import { prefixKeys } from '../utils/obj';

const { debug, warn, error } = createModuleDebug('platforms:generic');

export async function captureDefaultMetrics(eth: EthereumClient, captureTime: number): Promise<OutputMessage> {
export async function checkRpcMethodSupport(eth: EthereumClient, req: EthRequest<[], any>): Promise<boolean> {
try {
debug('Checking if RPC method %s is supported by ethereum node', req.method);
await eth.request(req, { immediate: true });
debug('Ethereum node seems to support RPC method %s', req.method);
return true;
} catch (e) {
warn('RPC method %s is not supported by ethereum node: %s', req.method, e.message);
return false;
}
}

export async function captureDefaultMetrics(
eth: EthereumClient,
captureTime: number,
supports: { hashRate?: boolean; peerCount?: boolean } = {}
): Promise<NodeMetricsMessage> {
const [blockNumberResult, hashRateResult, peerCountResult, gasPriceResult, syncStatus] = await Promise.all([
eth.request(blockNumber()),
eth.request(hashRate()),
eth.request(peerCount()),
supports.hashRate === false ? undefined : eth.request(hashRate()),
supports.peerCount === false ? undefined : eth.request(peerCount()),
eth
.request(gasPrice())
.then(value => bigIntToNumber(value))
Expand Down

0 comments on commit ee56c01

Please sign in to comment.