Skip to content

Commit

Permalink
chore: prepend version mismatch to GraphQL errors (#3580)
Browse files Browse the repository at this point in the history
* chore: refactored the gql error handler

* chore: moved incompatible node version from instantiation to error

* chore: add error to the subscription

* chore: changeset

---------

Co-authored-by: Sérgio Torres <[email protected]>
Co-authored-by: Anderson Arboleya <[email protected]>
  • Loading branch information
3 people authored Jan 17, 2025
1 parent 60caa27 commit edefe59
Show file tree
Hide file tree
Showing 7 changed files with 217 additions and 80 deletions.
5 changes: 5 additions & 0 deletions .changeset/angry-crabs-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fuel-ts/account": patch
---

chore: prepend version mismatch to GraphQL errors
10 changes: 4 additions & 6 deletions packages/account/src/providers/fuel-graphql-subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { ErrorCode, FuelError } from '@fuel-ts/errors';
import type { DocumentNode } from 'graphql';
import { print } from 'graphql';

import { assertGqlResponseHasNoErrors } from './utils/handle-gql-error-message';

type FuelGraphQLSubscriberOptions = {
url: string;
query: DocumentNode;
Expand All @@ -10,6 +12,7 @@ type FuelGraphQLSubscriberOptions = {
};

export class FuelGraphqlSubscriber implements AsyncIterator<unknown> {
public static incompatibleNodeVersionMessage: string | false = false;
private static textDecoder = new TextDecoder();

private constructor(private stream: ReadableStreamDefaultReader<Uint8Array>) {}
Expand Down Expand Up @@ -50,12 +53,7 @@ export class FuelGraphqlSubscriber implements AsyncIterator<unknown> {
if (this.events.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { data, errors } = this.events.shift()!;
if (Array.isArray(errors)) {
throw new FuelError(
FuelError.CODES.INVALID_REQUEST,
errors.map((err) => err.message).join('\n\n')
);
}
assertGqlResponseHasNoErrors(errors, FuelGraphqlSubscriber.incompatibleNodeVersionMessage);
return { value: data, done: false };
}
const { value, done } = await this.stream.read();
Expand Down
157 changes: 108 additions & 49 deletions packages/account/src/providers/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { InputType, OutputType, ReceiptType } from '@fuel-ts/transactions';
import { DateTime, arrayify, sleep } from '@fuel-ts/utils';
import { ASSET_A, ASSET_B } from '@fuel-ts/utils/test-utils';
import { versions } from '@fuel-ts/versions';
import * as fuelTsVersionsMod from '@fuel-ts/versions';

import { Wallet } from '..';
import {
messageStatusResponse,
MESSAGE_PROOF_RAW_RESPONSE,
Expand All @@ -20,6 +20,7 @@ import {
MOCK_TX_UNKNOWN_RAW_PAYLOAD,
MOCK_TX_SCRIPT_RAW_PAYLOAD,
} from '../../test/fixtures/transaction-summary';
import { mockIncompatibleVersions } from '../../test/utils/mockIncompabileVersions';
import { setupTestProviderAndWallets, launchNode, TestMessage } from '../test-utils';

import type { Coin } from './coin';
Expand Down Expand Up @@ -1148,74 +1149,132 @@ describe('Provider', () => {
expect(gasConfig.maxGasPerTx).toBeDefined();
});

it('warns on difference between major client version and supported major version', async () => {
const { FUEL_CORE } = versions;
const [major, minor, patch] = FUEL_CORE.split('.');
const majorMismatch = major === '0' ? 1 : parseInt(patch, 10) - 1;
it('Prepend a warning to an error with version mismatch [major]', async () => {
const { current, supported } = mockIncompatibleVersions({
isMajorMismatch: true,
isMinorMismatch: false,
});

const mock = {
isMajorSupported: false,
isMinorSupported: true,
isPatchSupported: true,
supportedVersion: `${majorMismatch}.${minor}.${patch}`,
};
using launched = await setupTestProviderAndWallets();
const {
provider: { url },
} = launched;

if (mock.supportedVersion === FUEL_CORE) {
throw new Error();
}
const provider = await new Provider(url).init();
const sender = Wallet.generate({ provider });
const receiver = Wallet.generate({ provider });

const spy = vi.spyOn(fuelTsVersionsMod, 'checkFuelCoreVersionCompatibility');
spy.mockImplementationOnce(() => mock);
await expectToThrowFuelError(() => sender.transfer(receiver.address, 1), {
code: ErrorCode.NOT_ENOUGH_FUNDS,
message: [
`The account(s) sending the transaction don't have enough funds to cover the transaction.`,
``,
`The Fuel Node that you are trying to connect to is using fuel-core version ${current.FUEL_CORE}.`,
`The TS SDK currently supports fuel-core version ${supported.FUEL_CORE}.`,
`Things may not work as expected.`,
].join('\n'),
});
});

const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('Prepend a warning to an error with version mismatch [minor]', async () => {
const { current, supported } = mockIncompatibleVersions({
isMajorMismatch: false,
isMinorMismatch: true,
});

using launched = await setupTestProviderAndWallets();
const { provider } = launched;
const {
provider: { url },
} = launched;

await new Provider(provider.url).init();
const provider = await new Provider(url).init();
const sender = Wallet.generate({ provider });
const receiver = Wallet.generate({ provider });

expect(consoleWarnSpy).toHaveBeenCalledOnce();
expect(consoleWarnSpy).toHaveBeenCalledWith(
`The Fuel Node that you are trying to connect to is using fuel-core version ${FUEL_CORE},
which is not supported by the version of the TS SDK that you are using.
Things may not work as expected.
Supported fuel-core version: ${mock.supportedVersion}.`
);
await expectToThrowFuelError(() => sender.transfer(receiver.address, 1), {
code: ErrorCode.NOT_ENOUGH_FUNDS,
message: [
`The account(s) sending the transaction don't have enough funds to cover the transaction.`,
``,
`The Fuel Node that you are trying to connect to is using fuel-core version ${current.FUEL_CORE}.`,
`The TS SDK currently supports fuel-core version ${supported.FUEL_CORE}.`,
`Things may not work as expected.`,
].join('\n'),
});
});

it('warns on difference between minor client version and supported minor version', async () => {
const { FUEL_CORE } = versions;
const [major, minor, patch] = FUEL_CORE.split('.');
const minorMismatch = minor === '0' ? 1 : parseInt(patch, 10) - 1;
it('Prepend a warning to a subscription error with version mismatch [major]', async () => {
const { current, supported } = mockIncompatibleVersions({
isMajorMismatch: true,
isMinorMismatch: false,
});

const mock = {
isMajorSupported: true,
isMinorSupported: false,
isPatchSupported: true,
supportedVersion: `${major}.${minorMismatch}.${patch}`,
};
using launched = await setupTestProviderAndWallets();
const { provider } = launched;

if (mock.supportedVersion === FUEL_CORE) {
throw new Error();
}
await expectToThrowFuelError(
async () => {
for await (const value of await provider.operations.statusChange({
transactionId: 'invalid transaction id',
})) {
// shouldn't be reached and should fail if reached
expect(value).toBeFalsy();
}
},

const spy = vi.spyOn(fuelTsVersionsMod, 'checkFuelCoreVersionCompatibility');
spy.mockImplementationOnce(() => mock);
{ code: FuelError.CODES.INVALID_REQUEST }
);

const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const chainId = await provider.getChainId();
const response = new TransactionResponse('invalid transaction id', provider, chainId);

await expectToThrowFuelError(() => response.waitForResult(), {
code: FuelError.CODES.INVALID_REQUEST,
message: [
`Failed to parse "TransactionId": Invalid character 'i' at position 0`,
``,
`The Fuel Node that you are trying to connect to is using fuel-core version ${current.FUEL_CORE}.`,
`The TS SDK currently supports fuel-core version ${supported.FUEL_CORE}.`,
`Things may not work as expected.`,
].join('\n'),
});
});

it('Prepend a warning to a subscription error with version mismatch [minor]', async () => {
const { current, supported } = mockIncompatibleVersions({
isMajorMismatch: false,
isMinorMismatch: true,
});

using launched = await setupTestProviderAndWallets();
const { provider } = launched;

await new Provider(provider.url).init();
await expectToThrowFuelError(
async () => {
for await (const value of await provider.operations.statusChange({
transactionId: 'invalid transaction id',
})) {
// shouldn't be reached and should fail if reached
expect(value).toBeFalsy();
}
},

expect(consoleWarnSpy).toHaveBeenCalledOnce();
expect(consoleWarnSpy).toHaveBeenCalledWith(
`The Fuel Node that you are trying to connect to is using fuel-core version ${FUEL_CORE},
which is not supported by the version of the TS SDK that you are using.
Things may not work as expected.
Supported fuel-core version: ${mock.supportedVersion}.`
{ code: FuelError.CODES.INVALID_REQUEST }
);

const chainId = await provider.getChainId();
const response = new TransactionResponse('invalid transaction id', provider, chainId);

await expectToThrowFuelError(() => response.waitForResult(), {
code: FuelError.CODES.INVALID_REQUEST,
message: [
`Failed to parse "TransactionId": Invalid character 'i' at position 0`,
``,
`The Fuel Node that you are trying to connect to is using fuel-core version ${current.FUEL_CORE}.`,
`The TS SDK currently supports fuel-core version ${supported.FUEL_CORE}.`,
`Things may not work as expected.`,
].join('\n'),
});
});

it('An invalid subscription request throws a FuelError and does not hold the test runner (closes all handles)', async () => {
Expand Down
32 changes: 16 additions & 16 deletions packages/account/src/providers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import {
} from './utils';
import type { RetryOptions } from './utils/auto-retry-fetch';
import { autoRetryFetch } from './utils/auto-retry-fetch';
import { handleGqlErrorMessage } from './utils/handle-gql-error-message';
import { assertGqlResponseHasNoErrors } from './utils/handle-gql-error-message';
import { validatePaginationArgs } from './utils/validate-pagination-args';

const MAX_RETRIES = 10;
Expand Down Expand Up @@ -414,6 +414,8 @@ export default class Provider {
private static chainInfoCache: ChainInfoCache = {};
/** @hidden */
private static nodeInfoCache: NodeInfoCache = {};
/** @hidden */
private static incompatibleNodeVersionMessage: string = '';

/** @hidden */
public consensusParametersTimestamp?: number;
Expand Down Expand Up @@ -609,7 +611,7 @@ export default class Provider {
vmBacktrace: data.nodeInfo.vmBacktrace,
};

Provider.ensureClientVersionIsSupported(nodeInfo);
Provider.setIncompatibleNodeVersionMessage(nodeInfo);

chain = processGqlChain(data.chain);

Expand All @@ -628,18 +630,18 @@ export default class Provider {
/**
* @hidden
*/
private static ensureClientVersionIsSupported(nodeInfo: NodeInfo) {
private static setIncompatibleNodeVersionMessage(nodeInfo: NodeInfo) {
const { isMajorSupported, isMinorSupported, supportedVersion } =
checkFuelCoreVersionCompatibility(nodeInfo.nodeVersion);

if (!isMajorSupported || !isMinorSupported) {
// eslint-disable-next-line no-console
console.warn(
`The Fuel Node that you are trying to connect to is using fuel-core version ${nodeInfo.nodeVersion},
which is not supported by the version of the TS SDK that you are using.
Things may not work as expected.
Supported fuel-core version: ${supportedVersion}.`
);
Provider.incompatibleNodeVersionMessage = [
`The Fuel Node that you are trying to connect to is using fuel-core version ${nodeInfo.nodeVersion}.`,
`The TS SDK currently supports fuel-core version ${supportedVersion}.`,
`Things may not work as expected.`,
].join('\n');
FuelGraphqlSubscriber.incompatibleNodeVersionMessage =
Provider.incompatibleNodeVersionMessage;
}
}

Expand All @@ -657,12 +659,10 @@ Supported fuel-core version: ${supportedVersion}.`
responseMiddleware: (response: GraphQLClientResponse<unknown> | Error) => {
if ('response' in response) {
const graphQlResponse = response.response as GraphQLResponse;

if (Array.isArray(graphQlResponse?.errors)) {
for (const error of graphQlResponse.errors) {
handleGqlErrorMessage(error.message, error);
}
}
assertGqlResponseHasNoErrors(
graphQlResponse.errors,
Provider.incompatibleNodeVersionMessage
);
}
},
});
Expand Down
52 changes: 45 additions & 7 deletions packages/account/src/providers/utils/handle-gql-error-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,61 @@ export enum GqlErrorMessage {
MAX_COINS_REACHED = 'max number of coins is reached while trying to fit the target',
}

export const handleGqlErrorMessage = (errorMessage: string, rawError: GraphQLError) => {
switch (errorMessage) {
type GqlError = { message: string } | GraphQLError;

const mapGqlErrorMessage = (error: GqlError): FuelError => {
switch (error.message) {
case GqlErrorMessage.NOT_ENOUGH_COINS:
throw new FuelError(
return new FuelError(
ErrorCode.NOT_ENOUGH_FUNDS,
`The account(s) sending the transaction don't have enough funds to cover the transaction.`,
{},
rawError
error
);
case GqlErrorMessage.MAX_COINS_REACHED:
throw new FuelError(
return new FuelError(
ErrorCode.MAX_COINS_REACHED,
'The account retrieving coins has exceeded the maximum number of coins per asset. Please consider combining your coins into a single UTXO.',
{},
rawError
error
);
default:
throw new FuelError(ErrorCode.INVALID_REQUEST, errorMessage);
return new FuelError(ErrorCode.INVALID_REQUEST, error.message, {}, error);
}
};

const mapGqlErrorWithIncompatibleNodeVersion = (
error: FuelError,
incompatibleNodeVersionMessage: string | false
) => {
if (!incompatibleNodeVersionMessage) {
return error;
}

return new FuelError(
error.code,
`${error.message}\n\n${incompatibleNodeVersionMessage}`,
error.metadata,
error.rawError
);
};

export const assertGqlResponseHasNoErrors = (
errors: GqlError[] | undefined,
incompatibleNodeVersionMessage: string | false = false
) => {
if (!Array.isArray(errors)) {
return;
}

const mappedErrors = errors.map(mapGqlErrorMessage);
if (mappedErrors.length === 1) {
throw mapGqlErrorWithIncompatibleNodeVersion(mappedErrors[0], incompatibleNodeVersionMessage);
}

const errorMessage = mappedErrors.map((err) => err.message).join('\n');
throw mapGqlErrorWithIncompatibleNodeVersion(
new FuelError(ErrorCode.INVALID_REQUEST, errorMessage, {}, mappedErrors),
incompatibleNodeVersionMessage
);
};
Loading

0 comments on commit edefe59

Please sign in to comment.