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

chore: prepend version mismatch to GraphQL errors #3580

Merged
merged 9 commits into from
Jan 17, 2025
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
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
Loading