diff --git a/.changeset/wide-sites-pull.md b/.changeset/wide-sites-pull.md new file mode 100644 index 0000000000..5c38cdad12 --- /dev/null +++ b/.changeset/wide-sites-pull.md @@ -0,0 +1,6 @@ +--- +"@human-protocol/sdk": minor +"@human-protocol/python-sdk": minor +--- + +Added new optional config for querying subgraph with retries when failuers are due to bad indexers errors in Python and Typescript SDK diff --git a/packages/sdk/python/human-protocol-sdk/example.py b/packages/sdk/python/human-protocol-sdk/example.py index ddc2c65264..43a874447c 100644 --- a/packages/sdk/python/human-protocol-sdk/example.py +++ b/packages/sdk/python/human-protocol-sdk/example.py @@ -18,6 +18,7 @@ from human_protocol_sdk.operator import OperatorUtils, OperatorFilter from human_protocol_sdk.agreement import agreement from human_protocol_sdk.staking.staking_utils import StakingUtils +from human_protocol_sdk.utils import SubgraphOptions def get_escrow_statistics(statistics_client: StatisticsClient): @@ -162,7 +163,8 @@ def get_escrows(): status=Status.Pending, date_from=datetime.datetime(2023, 5, 8), date_to=datetime.datetime(2023, 6, 8), - ) + ), + SubgraphOptions(3, 1000), ) ) @@ -232,12 +234,13 @@ def get_stakers_example(): chain_id=ChainId.POLYGON_AMOY, order_by="lastDepositTimestamp", order_direction=OrderDirection.ASC, - ) + ), + SubgraphOptions(3, 1000), ) print("Filtered stakers:", len(stakers)) if stakers: - staker = StakingUtils.get_staker(ChainId.LOCALHOST, stakers[0].address) + staker = StakingUtils.get_staker(ChainId.POLYGON_AMOY, stakers[0].address) print("Staker info:", staker.address) else: print("No stakers found.") diff --git a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/escrow/escrow_utils.py b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/escrow/escrow_utils.py index 3d1512a04a..5a5531e591 100644 --- a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/escrow/escrow_utils.py +++ b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/escrow/escrow_utils.py @@ -24,7 +24,6 @@ ------ """ -from datetime import datetime import logging from typing import List, Optional @@ -38,7 +37,8 @@ PayoutFilter, ) from human_protocol_sdk.utils import ( - get_data_from_subgraph, + SubgraphOptions, + custom_gql_fetch, ) from human_protocol_sdk.escrow.escrow_client import EscrowClientError @@ -219,10 +219,12 @@ class EscrowUtils: @staticmethod def get_escrows( filter: EscrowFilter, + options: Optional[SubgraphOptions] = None, ) -> List[EscrowData]: """Get an array of escrow addresses based on the specified filter parameters. :param filter: Object containing all the necessary parameters to filter + :param options: Optional config for subgraph requests :return: List of escrows @@ -257,7 +259,7 @@ def get_escrows( else: statuses = [filter.status.name] - escrows_data = get_data_from_subgraph( + escrows_data = custom_gql_fetch( network, query=get_escrows_query(filter), params={ @@ -283,6 +285,7 @@ def get_escrows( "skip": filter.skip, "orderDirection": filter.order_direction.value, }, + options=options, ) if ( @@ -334,11 +337,13 @@ def get_escrows( def get_escrow( chain_id: ChainId, escrow_address: str, + options: Optional[SubgraphOptions] = None, ) -> Optional[EscrowData]: """Returns the escrow for a given address. :param chain_id: Network in which the escrow has been deployed :param escrow_address: Address of the escrow + :param options: Optional config for subgraph requests :return: Escrow data @@ -367,12 +372,13 @@ def get_escrow( network = NETWORKS[ChainId(chain_id)] - escrow_data = get_data_from_subgraph( + escrow_data = custom_gql_fetch( network, query=get_escrow_query(), params={ "escrowAddress": escrow_address.lower(), }, + options=options, ) if ( @@ -414,11 +420,15 @@ def get_escrow( ) @staticmethod - def get_status_events(filter: StatusEventFilter) -> List[StatusEvent]: + def get_status_events( + filter: StatusEventFilter, + options: Optional[SubgraphOptions] = None, + ) -> List[StatusEvent]: """ Retrieve status events for specified networks and statuses within a date range. :param filter: Object containing all the necessary parameters to filter status events. + :param options: Optional config for subgraph requests :return List[StatusEvent]: List of status events matching the query parameters. @@ -435,7 +445,7 @@ def get_status_events(filter: StatusEventFilter) -> List[StatusEvent]: status_names = [status.name for status in filter.statuses] - data = get_data_from_subgraph( + data = custom_gql_fetch( network, get_status_query(filter.date_from, filter.date_to, filter.launcher), { @@ -447,6 +457,7 @@ def get_status_events(filter: StatusEventFilter) -> List[StatusEvent]: "skip": filter.skip, "orderDirection": filter.order_direction.value, }, + options=options, ) if ( @@ -472,11 +483,15 @@ def get_status_events(filter: StatusEventFilter) -> List[StatusEvent]: return events_with_chain_id @staticmethod - def get_payouts(filter: PayoutFilter) -> List[Payout]: + def get_payouts( + filter: PayoutFilter, + options: Optional[SubgraphOptions] = None, + ) -> List[Payout]: """ Fetch payouts from the subgraph based on the provided filter. :param filter: Object containing all the necessary parameters to filter payouts. + :param options: Optional config for subgraph requests :return List[Payout]: List of payouts matching the query parameters. @@ -494,7 +509,7 @@ def get_payouts(filter: PayoutFilter) -> List[Payout]: if not network: raise EscrowClientError("Unsupported Chain ID") - data = get_data_from_subgraph( + data = custom_gql_fetch( network, get_payouts_query(filter), { @@ -508,6 +523,7 @@ def get_payouts(filter: PayoutFilter) -> List[Payout]: "skip": filter.skip, "orderDirection": filter.order_direction.value, }, + options=options, ) if ( @@ -536,11 +552,13 @@ def get_payouts(filter: PayoutFilter) -> List[Payout]: @staticmethod def get_cancellation_refunds( filter: CancellationRefundFilter, + options: Optional[SubgraphOptions] = None, ) -> List[CancellationRefund]: """ Fetch cancellation refunds from the subgraph based on the provided filter. :param filter: Object containing all the necessary parameters to filter cancellation refunds. + :param options: Optional config for subgraph requests :return List[CancellationRefund]: List of cancellation refunds matching the query parameters. @@ -558,7 +576,7 @@ def get_cancellation_refunds( if not network: raise EscrowClientError("Unsupported Chain ID") - data = get_data_from_subgraph( + data = custom_gql_fetch( network, get_cancellation_refunds_query(filter), { @@ -572,6 +590,7 @@ def get_cancellation_refunds( "skip": filter.skip, "orderDirection": filter.order_direction.value, }, + options=options, ) if ( @@ -601,13 +620,16 @@ def get_cancellation_refunds( @staticmethod def get_cancellation_refund( - chain_id: ChainId, escrow_address: str + chain_id: ChainId, + escrow_address: str, + options: Optional[SubgraphOptions] = None, ) -> CancellationRefund: """ Returns the cancellation refund for a given escrow address. :param chain_id: Network in which the escrow has been deployed :param escrow_address: Address of the escrow + :param options: Optional config for subgraph requests :return: CancellationRefund data or None @@ -635,12 +657,13 @@ def get_cancellation_refund( if not network: raise EscrowClientError("Unsupported Chain ID") - data = get_data_from_subgraph( + data = custom_gql_fetch( network, get_cancellation_refund_by_escrow_query(), { "escrowAddress": escrow_address.lower(), }, + options=options, ) if ( diff --git a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/kvstore/kvstore_utils.py b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/kvstore/kvstore_utils.py index 9886b144eb..fdb371ddf2 100644 --- a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/kvstore/kvstore_utils.py +++ b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/kvstore/kvstore_utils.py @@ -29,7 +29,7 @@ import requests from human_protocol_sdk.constants import NETWORKS, ChainId, KVStoreKeys -from human_protocol_sdk.utils import get_data_from_subgraph +from human_protocol_sdk.utils import SubgraphOptions, custom_gql_fetch from human_protocol_sdk.kvstore.kvstore_client import KVStoreClientError @@ -57,11 +57,13 @@ class KVStoreUtils: def get_kvstore_data( chain_id: ChainId, address: str, + options: Optional[SubgraphOptions] = None, ) -> Optional[List[KVStoreData]]: """Returns the KVStore data for a given address. :param chain_id: Network in which the KVStore data has been deployed :param address: Address of the KVStore + :param options: Optional config for subgraph requests :return: List of KVStore data @@ -88,12 +90,13 @@ def get_kvstore_data( network = NETWORKS[ChainId(chain_id)] - kvstore_data = get_data_from_subgraph( + kvstore_data = custom_gql_fetch( network, query=get_kvstore_by_address_query(), params={ "address": address.lower(), }, + options=options, ) if ( @@ -111,12 +114,18 @@ def get_kvstore_data( ] @staticmethod - def get(chain_id: ChainId, address: str, key: str) -> str: + def get( + chain_id: ChainId, + address: str, + key: str, + options: Optional[SubgraphOptions] = None, + ) -> str: """Gets the value of a key-value pair in the contract. :param chain_id: Network in which the KVStore data has been deployed :param address: The Ethereum address associated with the key-value pair :param key: The key of the key-value pair to get + :param options: Optional config for subgraph requests :return: The value of the key-value pair if it exists @@ -142,13 +151,14 @@ def get(chain_id: ChainId, address: str, key: str) -> str: network = NETWORKS[ChainId(chain_id)] - kvstore_data = get_data_from_subgraph( + kvstore_data = custom_gql_fetch( network, query=get_kvstore_by_address_and_key_query(), params={ "address": address.lower(), "key": key, }, + options=options, ) if ( @@ -163,13 +173,17 @@ def get(chain_id: ChainId, address: str, key: str) -> str: @staticmethod def get_file_url_and_verify_hash( - chain_id: ChainId, address: str, key: Optional[str] = "url" + chain_id: ChainId, + address: str, + key: Optional[str] = "url", + options: Optional[SubgraphOptions] = None, ) -> str: """Gets the URL value of the given entity, and verify its hash. :param chain_id: Network in which the KVStore data has been deployed :param address: Address from which to get the URL value. :param key: Configurable URL key. `url` by default. + :param options: Optional config for subgraph requests :return url: The URL value of the given address if exists, and the content is valid @@ -189,8 +203,8 @@ def get_file_url_and_verify_hash( if not Web3.is_address(address): raise KVStoreClientError(f"Invalid address: {address}") - url = KVStoreUtils.get(chain_id, address, key) - hash = KVStoreUtils.get(chain_id, address, key + "_hash") + url = KVStoreUtils.get(chain_id, address, key, options=options) + hash = KVStoreUtils.get(chain_id, address, key + "_hash", options=options) if len(url) == 0: return url diff --git a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/operator/operator_utils.py b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/operator/operator_utils.py index 9bed4d8fc6..c4332ea4fb 100644 --- a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/operator/operator_utils.py +++ b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/operator/operator_utils.py @@ -25,7 +25,7 @@ from human_protocol_sdk.constants import NETWORKS, ChainId, OrderDirection from human_protocol_sdk.gql.reward import get_reward_added_events_query -from human_protocol_sdk.utils import get_data_from_subgraph +from human_protocol_sdk.utils import SubgraphOptions, custom_gql_fetch from web3 import Web3 LOG = logging.getLogger("human_protocol_sdk.operator") @@ -198,10 +198,14 @@ class OperatorUtils: """ @staticmethod - def get_operators(filter: OperatorFilter) -> List[OperatorData]: + def get_operators( + filter: OperatorFilter, + options: Optional[SubgraphOptions] = None, + ) -> List[OperatorData]: """Get operators data of the protocol. :param filter: Operator filter + :param options: Optional config for subgraph requests :return: List of operators data @@ -226,7 +230,7 @@ def get_operators(filter: OperatorFilter) -> List[OperatorData]: if not network.get("subgraph_url"): return [] - operators_data = get_data_from_subgraph( + operators_data = custom_gql_fetch( network, query=get_operators_query(filter), params={ @@ -237,6 +241,7 @@ def get_operators(filter: OperatorFilter) -> List[OperatorData]: "first": filter.first, "skip": filter.skip, }, + options=options, ) if ( @@ -283,11 +288,13 @@ def get_operators(filter: OperatorFilter) -> List[OperatorData]: def get_operator( chain_id: ChainId, operator_address: str, + options: Optional[SubgraphOptions] = None, ) -> Optional[OperatorData]: """Gets the operator details. :param chain_id: Network in which the operator exists :param operator_address: Address of the operator + :param options: Optional config for subgraph requests :return: Operator data if exists, otherwise None @@ -314,10 +321,11 @@ def get_operator( network = NETWORKS[chain_id] - operator_data = get_data_from_subgraph( + operator_data = custom_gql_fetch( network, query=get_operator_query, params={"address": operator_address.lower()}, + options=options, ) if ( @@ -359,12 +367,14 @@ def get_reputation_network_operators( chain_id: ChainId, address: str, role: Optional[str] = None, + options: Optional[SubgraphOptions] = None, ) -> List[OperatorData]: """Get the reputation network operators of the specified address. :param chain_id: Network in which the reputation network exists :param address: Address of the reputation oracle :param role: (Optional) Role of the operator + :param options: Optional config for subgraph requests :return: Returns an array of operator details @@ -391,10 +401,11 @@ def get_reputation_network_operators( network = NETWORKS[chain_id] - reputation_network_data = get_data_from_subgraph( + reputation_network_data = custom_gql_fetch( network, query=get_reputation_network_query(role), params={"address": address.lower(), "role": role}, + options=options, ) if ( @@ -438,11 +449,16 @@ def get_reputation_network_operators( return result @staticmethod - def get_rewards_info(chain_id: ChainId, slasher: str) -> List[RewardData]: + def get_rewards_info( + chain_id: ChainId, + slasher: str, + options: Optional[SubgraphOptions] = None, + ) -> List[RewardData]: """Get rewards of the given slasher. :param chain_id: Network in which the slasher exists :param slasher: Address of the slasher + :param options: Optional config for subgraph requests :return: List of rewards info @@ -467,10 +483,11 @@ def get_rewards_info(chain_id: ChainId, slasher: str) -> List[RewardData]: network = NETWORKS[chain_id] - reward_added_events_data = get_data_from_subgraph( + reward_added_events_data = custom_gql_fetch( network, query=get_reward_added_events_query, params={"slasherAddress": slasher.lower()}, + options=options, ) if ( diff --git a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/staking/staking_utils.py b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/staking/staking_utils.py index 2da710d030..74915e278b 100644 --- a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/staking/staking_utils.py +++ b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/staking/staking_utils.py @@ -30,7 +30,7 @@ from typing import List, Optional from human_protocol_sdk.constants import NETWORKS, ChainId from human_protocol_sdk.filter import StakersFilter -from human_protocol_sdk.utils import get_data_from_subgraph +from human_protocol_sdk.utils import SubgraphOptions, custom_gql_fetch from human_protocol_sdk.gql.staking import get_staker_query, get_stakers_query @@ -62,15 +62,20 @@ class StakingUtilsError(Exception): class StakingUtils: @staticmethod - def get_staker(chain_id: ChainId, address: str) -> Optional[StakerData]: + def get_staker( + chain_id: ChainId, + address: str, + options: Optional[SubgraphOptions] = None, + ) -> Optional[StakerData]: network = NETWORKS.get(chain_id) if not network: raise StakingUtilsError("Unsupported Chain ID") - data = get_data_from_subgraph( + data = custom_gql_fetch( network, query=get_staker_query(), params={"id": address.lower()}, + options=options, ) if ( not data @@ -93,12 +98,15 @@ def get_staker(chain_id: ChainId, address: str) -> Optional[StakerData]: ) @staticmethod - def get_stakers(filter: StakersFilter) -> List[StakerData]: + def get_stakers( + filter: StakersFilter, + options: Optional[SubgraphOptions] = None, + ) -> List[StakerData]: network_data = NETWORKS.get(filter.chain_id) if not network_data: raise StakingUtilsError("Unsupported Chain ID") - data = get_data_from_subgraph( + data = custom_gql_fetch( network_data, query=get_stakers_query(filter), params={ @@ -115,6 +123,7 @@ def get_stakers(filter: StakersFilter) -> List[StakerData]: "first": filter.first, "skip": filter.skip, }, + options=options, ) if ( not data diff --git a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/statistics/statistics_client.py b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/statistics/statistics_client.py index 3a42205689..b78f927418 100644 --- a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/statistics/statistics_client.py +++ b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/statistics/statistics_client.py @@ -22,7 +22,7 @@ from human_protocol_sdk.constants import ChainId, NETWORKS -from human_protocol_sdk.utils import get_data_from_subgraph +from human_protocol_sdk.utils import SubgraphOptions, custom_gql_fetch from human_protocol_sdk.filter import StatisticsFilter LOG = logging.getLogger("human_protocol_sdk.statistics") @@ -290,11 +290,14 @@ def __init__(self, chain_id: ChainId = ChainId.POLYGON_AMOY): raise StatisticsClientError("Empty network configuration") def get_escrow_statistics( - self, filter: StatisticsFilter = StatisticsFilter() + self, + filter: StatisticsFilter = StatisticsFilter(), + options: Optional[SubgraphOptions] = None, ) -> EscrowStatistics: """Get escrow statistics data for the given date range. :param filter: Object containing the date range + :param options: Optional config for subgraph requests :return: Escrow statistics data @@ -323,13 +326,14 @@ def get_escrow_statistics( get_escrow_statistics_query, ) - escrow_statistics_data = get_data_from_subgraph( + escrow_statistics_data = custom_gql_fetch( self.network, query=get_escrow_statistics_query, + options=options, ) escrow_statistics = escrow_statistics_data["data"]["escrowStatistics"] - event_day_datas_data = get_data_from_subgraph( + event_day_datas_data = custom_gql_fetch( self.network, query=get_event_day_data_query(filter), params={ @@ -339,6 +343,7 @@ def get_escrow_statistics( "skip": filter.skip, "orderDirection": filter.order_direction.value, }, + options=options, ) event_day_datas = event_day_datas_data["data"]["eventDayDatas"] @@ -368,11 +373,14 @@ def get_escrow_statistics( ) def get_worker_statistics( - self, filter: StatisticsFilter = StatisticsFilter() + self, + filter: StatisticsFilter = StatisticsFilter(), + options: Optional[SubgraphOptions] = None, ) -> WorkerStatistics: """Get worker statistics data for the given date range. :param filter: Object containing the date range + :param options: Optional config for subgraph requests :return: Worker statistics data @@ -399,7 +407,7 @@ def get_worker_statistics( get_event_day_data_query, ) - event_day_datas_data = get_data_from_subgraph( + event_day_datas_data = custom_gql_fetch( self.network, query=get_event_day_data_query(filter), params={ @@ -409,6 +417,7 @@ def get_worker_statistics( "skip": filter.skip, "orderDirection": filter.order_direction.value, }, + options=options, ) event_day_datas = event_day_datas_data["data"]["eventDayDatas"] @@ -425,11 +434,14 @@ def get_worker_statistics( ) def get_payment_statistics( - self, filter: StatisticsFilter = StatisticsFilter() + self, + filter: StatisticsFilter = StatisticsFilter(), + options: Optional[SubgraphOptions] = None, ) -> PaymentStatistics: """Get payment statistics data for the given date range. :param filter: Object containing the date range + :param options: Optional config for subgraph requests :return: Payment statistics data @@ -457,7 +469,7 @@ def get_payment_statistics( get_event_day_data_query, ) - event_day_datas_data = get_data_from_subgraph( + event_day_datas_data = custom_gql_fetch( self.network, query=get_event_day_data_query(filter), params={ @@ -467,6 +479,7 @@ def get_payment_statistics( "skip": filter.skip, "orderDirection": filter.order_direction.value, }, + options=options, ) event_day_datas = event_day_datas_data["data"]["eventDayDatas"] @@ -491,9 +504,13 @@ def get_payment_statistics( ], ) - def get_hmt_statistics(self) -> HMTStatistics: + def get_hmt_statistics( + self, options: Optional[SubgraphOptions] = None + ) -> HMTStatistics: """Get HMT statistics data. + :param options: Optional config for subgraph requests + :return: HMT statistics data :example: @@ -511,9 +528,10 @@ def get_hmt_statistics(self) -> HMTStatistics: get_hmtoken_statistics_query, ) - hmtoken_statistics_data = get_data_from_subgraph( + hmtoken_statistics_data = custom_gql_fetch( self.network, query=get_hmtoken_statistics_query, + options=options, ) hmtoken_statistics = hmtoken_statistics_data["data"]["hmtokenStatistics"] @@ -528,11 +546,14 @@ def get_hmt_statistics(self) -> HMTStatistics: ) def get_hmt_holders( - self, param: HMTHoldersParam = HMTHoldersParam() + self, + param: HMTHoldersParam = HMTHoldersParam(), + options: Optional[SubgraphOptions] = None, ) -> List[HMTHolder]: """Get HMT holders data with optional filters and ordering. :param param: Object containing filter and order parameters + :param options: Optional config for subgraph requests :return: List of HMT holders @@ -556,7 +577,7 @@ def get_hmt_holders( """ from human_protocol_sdk.gql.hmtoken import get_holders_query - holders_data = get_data_from_subgraph( + holders_data = custom_gql_fetch( self.network, query=get_holders_query(address=param.address), params={ @@ -564,6 +585,7 @@ def get_hmt_holders( "orderBy": "balance", "orderDirection": param.order_direction, }, + options=options, ) holders = holders_data["data"]["holders"] @@ -577,11 +599,14 @@ def get_hmt_holders( ] def get_hmt_daily_data( - self, filter: StatisticsFilter = StatisticsFilter() + self, + filter: StatisticsFilter = StatisticsFilter(), + options: Optional[SubgraphOptions] = None, ) -> List[DailyHMTData]: """Get HMT daily statistics data for the given date range. :param filter: Object containing the date range + :param options: Optional config for subgraph requests :return: HMT statistics data @@ -607,7 +632,7 @@ def get_hmt_daily_data( get_event_day_data_query, ) - event_day_datas_data = get_data_from_subgraph( + event_day_datas_data = custom_gql_fetch( self.network, query=get_event_day_data_query(filter), params={ @@ -617,6 +642,7 @@ def get_hmt_daily_data( "skip": filter.skip, "orderDirection": filter.order_direction.value, }, + options=options, ) event_day_datas = event_day_datas_data["data"]["eventDayDatas"] diff --git a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/transaction/transaction_utils.py b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/transaction/transaction_utils.py index 1b88aa89da..ee725ff1b0 100644 --- a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/transaction/transaction_utils.py +++ b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/transaction/transaction_utils.py @@ -30,7 +30,7 @@ from human_protocol_sdk.constants import NETWORKS, ChainId from web3 import Web3 from human_protocol_sdk.filter import TransactionFilter -from human_protocol_sdk.utils import get_data_from_subgraph +from human_protocol_sdk.utils import SubgraphOptions, custom_gql_fetch class InternalTransaction: @@ -97,11 +97,14 @@ class TransactionUtils: """ @staticmethod - def get_transaction(chain_id: ChainId, hash: str) -> Optional[TransactionData]: + def get_transaction( + chain_id: ChainId, hash: str, options: Optional[SubgraphOptions] = None + ) -> Optional[TransactionData]: """Returns the transaction for a given hash. :param chain_id: Network in which the transaction was executed :param hash: Hash of the transaction + :param options: Optional config for subgraph requests :return: Transaction data @@ -124,10 +127,11 @@ def get_transaction(chain_id: ChainId, hash: str) -> Optional[TransactionData]: from human_protocol_sdk.gql.transaction import get_transaction_query - transaction_data = get_data_from_subgraph( + transaction_data = custom_gql_fetch( network, query=get_transaction_query(), params={"hash": hash.lower()}, + options=options, ) if ( not transaction_data @@ -166,11 +170,14 @@ def get_transaction(chain_id: ChainId, hash: str) -> Optional[TransactionData]: ) @staticmethod - def get_transactions(filter: TransactionFilter) -> List[TransactionData]: + def get_transactions( + filter: TransactionFilter, options: Optional[SubgraphOptions] = None + ) -> List[TransactionData]: """Get an array of transactions based on the specified filter parameters. :param filter: Object containing all the necessary parameters to filter (chain_id, from_address, to_address, start_date, end_date, start_block, end_block, method, escrow, token, first, skip, order_direction) + :param options: Optional config for subgraph requests :return: List of transactions @@ -200,7 +207,7 @@ def get_transactions(filter: TransactionFilter) -> List[TransactionData]: if not network_data: raise TransactionUtilsError("Unsupported Chain ID") - data = get_data_from_subgraph( + data = custom_gql_fetch( network_data, query=get_transactions_query(filter), params={ @@ -223,6 +230,7 @@ def get_transactions(filter: TransactionFilter) -> List[TransactionData]: "skip": filter.skip, "orderDirection": filter.order_direction.value, }, + options=options, ) if ( not data diff --git a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/utils.py b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/utils.py index c9dd1c7bf5..55d0ea905b 100644 --- a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/utils.py +++ b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/utils.py @@ -4,6 +4,7 @@ import time import re from typing import Tuple, Optional +from dataclasses import dataclass import requests from validators import url as URL @@ -32,41 +33,118 @@ pass -def with_retry(fn, retries=3, delay=5, backoff=2): - """Retry a function +@dataclass +class SubgraphOptions: + """Configuration for subgraph logic.""" - Mainly used with handle_transaction to retry on case of failure. - Uses exponential backoff. + max_retries: Optional[int] = None + base_delay: Optional[int] = None # milliseconds - :param fn: to run with retry logic. - :param retries: number of times to retry the transaction - :param delay: time to wait (exponentially) - :param backoff: defines the rate of grow for the exponential wait. - :return: False if transaction never succeeded, - otherwise the return of the function +def is_indexer_error(error: Exception) -> bool: + """ + Check if an error indicates that the indexer is down or not synced. + This function specifically checks for "bad indexers" errors from The Graph. - :note: If the partial returns a Boolean and it happens to be False, - we would not know if the tx succeeded and it will retry. + :param error: The error to check + :return: True if the error indicates indexer issues """ + if not error: + return False - wait_time = delay + response = getattr(error, "response", None) - for i in range(retries): + message = "" + if response is not None: try: - result = fn() - if result: - return result - except Exception as e: - name = getattr(fn, "__name__", "partial") - logger.warning( - f"(x{i+1}) {name} exception: {e}. Retrying after {wait_time} sec..." - ) + data = response.json() + except Exception: + data = None + + if isinstance(data, dict): + errors = data.get("errors") + if isinstance(errors, list) and errors: + first_error = errors[0] + if isinstance(first_error, dict): + message = str(first_error.get("message", "")) + + if not message: + message = getattr(error, "message", "") or str(error) or "" + + return "bad indexers" in message.lower() + + +def custom_gql_fetch( + network: dict, + query: str, + params: dict = None, + options: Optional[SubgraphOptions] = None, +): + """Fetch data from the subgraph with optional logic. + + :param network: Network configuration dictionary + :param query: GraphQL query string + :param params: Query parameters + :param options: Optional subgraph configuration + + :return: JSON response from the subgraph - time.sleep(wait_time) - wait_time *= backoff + :raise Exception: If the subgraph query fails + """ + if not options: + return _fetch_subgraph_data(network, query, params) + + if ( + options.max_retries is not None + and options.base_delay is None + or options.max_retries is None + and options.base_delay is not None + ): + raise ValueError( + "Retry configuration must include both max_retries and base_delay" + ) - return False + max_retries = int(options.max_retries) + base_delay = options.base_delay / 1000 + + last_error = None + + for attempt in range(max_retries + 1): + try: + return _fetch_subgraph_data(network, query, params) + except Exception as error: + last_error = error + + if not is_indexer_error(error): + break + + delay = base_delay * attempt + time.sleep(delay) + + raise last_error + + +def _fetch_subgraph_data(network: dict, query: str, params: dict = None): + subgraph_api_key = os.getenv("SUBGRAPH_API_KEY", "") + if subgraph_api_key: + subgraph_url = network["subgraph_url_api_key"].replace( + SUBGRAPH_API_KEY_PLACEHOLDER, subgraph_api_key + ) + else: + logger.warning( + "Warning: SUBGRAPH_API_KEY is not provided. It might cause issues with the subgraph." + ) + subgraph_url = network["subgraph_url"] + + request = requests.post(subgraph_url, json={"query": query, "variables": params}) + if request.status_code == 200: + return request.json() + else: + raise Exception( + "Subgraph query failed. return code is {}. \n{}".format( + request.status_code, query + ) + ) def get_hmt_balance(wallet_addr, token_addr, w3): @@ -192,39 +270,6 @@ def get_kvstore_interface(): ) -def get_data_from_subgraph(network: dict, query: str, params: dict = None): - """Fetch data from the subgraph. - - :param network: Network configuration dictionary - :param query: GraphQL query string - :param params: Query parameters - - :return: JSON response from the subgraph - - :raise Exception: If the subgraph query fails - """ - subgraph_api_key = os.getenv("SUBGRAPH_API_KEY", "") - if subgraph_api_key: - subgraph_url = network["subgraph_url_api_key"].replace( - SUBGRAPH_API_KEY_PLACEHOLDER, subgraph_api_key - ) - else: - logger.warning( - "Warning: SUBGRAPH_API_KEY is not provided. It might cause issues with the subgraph." - ) - subgraph_url = network["subgraph_url"] - - request = requests.post(subgraph_url, json={"query": query, "variables": params}) - if request.status_code == 200: - return request.json() - else: - raise Exception( - "Subgraph query failed. return code is {}. \n{}".format( - request.status_code, query - ) - ) - - def handle_error(e, exception_class): """ Handles and translates errors raised during contract transactions. diff --git a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/worker/worker_utils.py b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/worker/worker_utils.py index c363898ce6..1bdab5ca14 100644 --- a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/worker/worker_utils.py +++ b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/worker/worker_utils.py @@ -4,7 +4,7 @@ from web3 import Web3 from human_protocol_sdk.constants import NETWORKS, ChainId -from human_protocol_sdk.utils import get_data_from_subgraph +from human_protocol_sdk.utils import SubgraphOptions, custom_gql_fetch from human_protocol_sdk.filter import WorkerFilter LOG = logging.getLogger("human_protocol_sdk.worker") @@ -47,10 +47,14 @@ class WorkerUtils: """ @staticmethod - def get_workers(filter: WorkerFilter) -> List[WorkerData]: + def get_workers( + filter: WorkerFilter, + options: Optional[SubgraphOptions] = None, + ) -> List[WorkerData]: """Get workers data of the protocol. :param filter: Worker filter + :param options: Optional config for subgraph requests :return: List of workers data """ @@ -62,7 +66,7 @@ def get_workers(filter: WorkerFilter) -> List[WorkerData]: if not network: raise WorkerUtilsError("Unsupported Chain ID") - workers_data = get_data_from_subgraph( + workers_data = custom_gql_fetch( network, query=get_workers_query(filter), params={ @@ -72,6 +76,7 @@ def get_workers(filter: WorkerFilter) -> List[WorkerData]: "first": filter.first, "skip": filter.skip, }, + options=options, ) if ( @@ -97,11 +102,16 @@ def get_workers(filter: WorkerFilter) -> List[WorkerData]: return workers @staticmethod - def get_worker(chain_id: ChainId, worker_address: str) -> Optional[WorkerData]: + def get_worker( + chain_id: ChainId, + worker_address: str, + options: Optional[SubgraphOptions] = None, + ) -> Optional[WorkerData]: """Gets the worker details. :param chain_id: Network in which the worker exists :param worker_address: Address of the worker + :param options: Optional config for subgraph requests :return: Worker data if exists, otherwise None """ @@ -116,10 +126,11 @@ def get_worker(chain_id: ChainId, worker_address: str) -> Optional[WorkerData]: raise WorkerUtilsError(f"Invalid operator address: {worker_address}") network = NETWORKS[chain_id] - worker_data = get_data_from_subgraph( + worker_data = custom_gql_fetch( network, query=get_worker_query(), params={"address": worker_address.lower()}, + options=options, ) if ( diff --git a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/escrow/test_escrow_utils.py b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/escrow/test_escrow_utils.py index 88b52db16e..3a61638d6b 100644 --- a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/escrow/test_escrow_utils.py +++ b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/escrow/test_escrow_utils.py @@ -23,7 +23,7 @@ class TestEscrowUtils(unittest.TestCase): def test_get_escrows(self): with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" ) as mock_function: mock_escrow = { "id": "0x1234567890123456789012345678901234567891", @@ -51,7 +51,7 @@ def test_get_escrows(self): "createdAt": "1683811973", } - def side_effect(subgraph_url, query, params): + def side_effect(subgraph_url, query, params, options): if subgraph_url == NETWORKS[ChainId.POLYGON_AMOY]: return {"data": {"escrows": [mock_escrow]}} @@ -83,6 +83,7 @@ def side_effect(subgraph_url, query, params): "skip": 0, "orderDirection": "desc", }, + options=None, ) self.assertEqual(len(filtered), 1) self.assertEqual(filtered[0].address, mock_escrow["address"]) @@ -154,6 +155,7 @@ def side_effect(subgraph_url, query, params): "skip": 0, "orderDirection": "desc", }, + options=None, ) self.assertEqual(len(filtered), 1) self.assertEqual(filtered[0].chain_id, ChainId.POLYGON_AMOY) @@ -161,7 +163,7 @@ def side_effect(subgraph_url, query, params): def test_get_escrows_with_status_array(self): """Test get_escrows with an array of statuses, similar to the TypeScript test.""" with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" ) as mock_function: mock_escrow_1 = { "id": "0x1234567890123456789012345678901234567891", @@ -204,7 +206,7 @@ def test_get_escrows_with_status_array(self): "createdAt": "1672531200000", } - def side_effect(subgraph_url, query, params): + def side_effect(subgraph_url, query, params, options): if subgraph_url == NETWORKS[ChainId.POLYGON_AMOY]: return {"data": {"escrows": [mock_escrow_1, mock_escrow_2]}} @@ -232,6 +234,7 @@ def side_effect(subgraph_url, query, params): "skip": 0, "orderDirection": "desc", }, + options=None, ) self.assertEqual(len(filtered), 2) self.assertEqual(filtered[0].address, mock_escrow_1["address"]) @@ -239,7 +242,7 @@ def side_effect(subgraph_url, query, params): def test_get_escrow(self): with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" ) as mock_function: mock_escrow = { "id": "0x1234567890123456789012345678901234567891", @@ -284,6 +287,7 @@ def test_get_escrow(self): params={ "escrowAddress": "0x1234567890123456789012345678901234567890", }, + options=None, ) self.assertEqual(escrow.chain_id, ChainId.POLYGON_AMOY) self.assertEqual(escrow.address, mock_escrow["address"]) @@ -325,7 +329,7 @@ def test_get_escrow(self): def test_get_escrow_empty_data(self): with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = { "data": { @@ -343,6 +347,7 @@ def test_get_escrow_empty_data(self): params={ "escrowAddress": "0x1234567890123456789012345678901234567890", }, + options=None, ) self.assertEqual(escrow, None) @@ -367,9 +372,9 @@ def test_get_status_events_invalid_launcher(self): def test_get_status_events(self): with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" - ) as mock_get_data_from_subgraph: - mock_get_data_from_subgraph.return_value = { + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" + ) as mock_custom_gql_fetch: + mock_custom_gql_fetch.return_value = { "data": { "escrowStatusEvents": [ { @@ -394,9 +399,9 @@ def test_get_status_events(self): def test_get_status_events_with_date_range(self): with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" - ) as mock_get_data_from_subgraph: - mock_get_data_from_subgraph.return_value = { + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" + ) as mock_custom_gql_fetch: + mock_custom_gql_fetch.return_value = { "data": { "escrowStatusEvents": [ { @@ -427,9 +432,9 @@ def test_get_status_events_with_date_range(self): def test_get_status_events_no_data(self): with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" - ) as mock_get_data_from_subgraph: - mock_get_data_from_subgraph.return_value = {"data": {}} + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" + ) as mock_custom_gql_fetch: + mock_custom_gql_fetch.return_value = {"data": {}} filter = StatusEventFilter( chain_id=ChainId.POLYGON_AMOY, statuses=[Status.Pending] @@ -440,9 +445,9 @@ def test_get_status_events_no_data(self): def test_get_status_events_with_launcher(self): with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" - ) as mock_get_data_from_subgraph: - mock_get_data_from_subgraph.return_value = { + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" + ) as mock_custom_gql_fetch: + mock_custom_gql_fetch.return_value = { "data": { "escrowStatusEvents": [ { @@ -487,9 +492,9 @@ def test_get_payouts_invalid_recipient(self): def test_get_payouts(self): with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" - ) as mock_get_data_from_subgraph: - mock_get_data_from_subgraph.return_value = { + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" + ) as mock_custom_gql_fetch: + mock_custom_gql_fetch.return_value = { "data": { "payouts": [ { @@ -519,9 +524,9 @@ def test_get_payouts(self): def test_get_payouts_with_filters(self): with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" - ) as mock_get_data_from_subgraph: - mock_get_data_from_subgraph.return_value = { + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" + ) as mock_custom_gql_fetch: + mock_custom_gql_fetch.return_value = { "data": { "payouts": [ { @@ -557,9 +562,9 @@ def test_get_payouts_with_filters(self): def test_get_payouts_no_data(self): with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" - ) as mock_get_data_from_subgraph: - mock_get_data_from_subgraph.return_value = {"data": {"payouts": []}} + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" + ) as mock_custom_gql_fetch: + mock_custom_gql_fetch.return_value = {"data": {"payouts": []}} filter = PayoutFilter(chain_id=ChainId.POLYGON_AMOY) result = EscrowUtils.get_payouts(filter) @@ -568,9 +573,9 @@ def test_get_payouts_no_data(self): def test_get_payouts_with_pagination(self): with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" - ) as mock_get_data_from_subgraph: - mock_get_data_from_subgraph.return_value = { + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" + ) as mock_custom_gql_fetch: + mock_custom_gql_fetch.return_value = { "data": { "payouts": [ { @@ -604,7 +609,7 @@ def test_get_cancellation_refunds(self): from human_protocol_sdk.escrow.escrow_utils import CancellationRefundFilter with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" ) as mock_function: mock_refund = { "id": "1", @@ -616,7 +621,7 @@ def test_get_cancellation_refunds(self): "txHash": "0xhash1", } - def side_effect(subgraph_url, query, params): + def side_effect(subgraph_url, query, params, options): if subgraph_url == NETWORKS[ChainId.POLYGON_AMOY]: return {"data": {"cancellationRefundEvents": [mock_refund]}} @@ -674,7 +679,7 @@ def test_get_cancellation_refunds_no_data(self): from human_protocol_sdk.escrow.escrow_utils import CancellationRefundFilter with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = {"data": {"cancellationRefundEvents": []}} @@ -685,7 +690,7 @@ def test_get_cancellation_refunds_no_data(self): def test_get_cancellation_refund(self): with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" ) as mock_function: mock_refund = { "id": "1", @@ -720,7 +725,7 @@ def test_get_cancellation_refund(self): def test_get_cancellation_refund_no_data(self): with patch( - "human_protocol_sdk.escrow.escrow_utils.get_data_from_subgraph" + "human_protocol_sdk.escrow.escrow_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = {"data": {"cancellationRefundEvents": []}} diff --git a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/kvstore/test_kvstore_utils.py b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/kvstore/test_kvstore_utils.py index 35e590715b..2b39eec467 100644 --- a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/kvstore/test_kvstore_utils.py +++ b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/kvstore/test_kvstore_utils.py @@ -13,7 +13,7 @@ class TestKVStoreUtils(unittest.TestCase): def test_get_kvstore_data(self): with patch( - "human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph" + "human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch" ) as mock_function: mock_kvstore_data = [ { @@ -51,6 +51,7 @@ def test_get_kvstore_data(self): params={ "address": "0x15d34aaf54267db7d7c367839aaf71a00a2c6a65", }, + options=None, ) self.assertEqual(len(kvstores), 2) self.assertEqual(kvstores[0].key, "fee") @@ -58,7 +59,7 @@ def test_get_kvstore_data(self): def test_get_kvstore_data_empty_data(self): with patch( - "human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph" + "human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = { "data": { @@ -76,6 +77,7 @@ def test_get_kvstore_data_empty_data(self): params={ "address": "0x15d34aaf54267db7d7c367839aaf71a00a2c6a65", }, + options=None, ) self.assertEqual(kvstores, []) @@ -91,7 +93,7 @@ def test_get_kvstore_data_invalid_address(self): KVStoreUtils.get_kvstore_data(ChainId.LOCALHOST, "invalid_address") self.assertEqual("Invalid KVStore address: invalid_address", str(cm.exception)) - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get(self, mock_function): address = Web3.to_checksum_address("0x1234567890123456789012345678901234567890") key = "key1" @@ -118,10 +120,11 @@ def test_get(self, mock_function): NETWORKS[ChainId.LOCALHOST], query=get_kvstore_by_address_and_key_query(), params={"address": address, "key": key}, + options=None, ) self.assertEqual(result, "1") - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_empty_key(self, mock_function): address = Web3.to_checksum_address("0x1234567890123456789012345678901234567890") key = "" @@ -129,7 +132,7 @@ def test_get_empty_key(self, mock_function): KVStoreUtils.get(ChainId.LOCALHOST, address, key) self.assertEqual("Key cannot be empty", str(cm.exception)) - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_invalid_address(self, mock_function): address = "invalid_address" key = "key" @@ -137,7 +140,7 @@ def test_get_invalid_address(self, mock_function): KVStoreUtils.get(ChainId.LOCALHOST, address, key) self.assertEqual(f"Invalid address: {address}", str(cm.exception)) - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_empty_value(self, mock_function): mock_function.return_value = {"data": {"kvstores": []}} @@ -154,9 +157,10 @@ def test_get_empty_value(self, mock_function): NETWORKS[ChainId.LOCALHOST], query=get_kvstore_by_address_and_key_query(), params={"address": address, "key": key}, + options=None, ) - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_without_account(self, mock_function): mock_function.return_value = { "data": { @@ -182,10 +186,11 @@ def test_get_without_account(self, mock_function): NETWORKS[ChainId.LOCALHOST], query=get_kvstore_by_address_and_key_query(), params={"address": address, "key": key}, + options=None, ) self.assertEqual(result, "mock_value") - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_file_url_and_verify_hash(self, mock_function): mock_function.side_effect = [ {"data": {"kvstores": [{"value": "https://example.com"}]}}, @@ -204,7 +209,7 @@ def test_get_file_url_and_verify_hash(self, mock_function): self.assertEqual(result, "https://example.com") - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_file_url_and_verify_hash_with_key(self, mock_function): mock_function.side_effect = [ {"data": {"kvstores": [{"value": "https://example.com"}]}}, @@ -223,14 +228,14 @@ def test_get_file_url_and_verify_hash_with_key(self, mock_function): self.assertEqual(result, "https://example.com") - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_file_url_and_verify_hash_invalid_address(self, mock_function): address = "invalid_address" with self.assertRaises(KVStoreClientError) as cm: KVStoreUtils.get_file_url_and_verify_hash(ChainId.LOCALHOST, address) self.assertEqual(f"Invalid address: {address}", str(cm.exception)) - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_file_url_and_verify_hash_empty_value(self, mock_function): mock_function.return_value = {"data": {"kvstores": []}} @@ -243,7 +248,7 @@ def test_get_file_url_and_verify_hash_empty_value(self, mock_function): f"Key '{key}' not found for address {address}", str(cm.exception) ) - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_file_url_and_verify_hash_without_account(self, mock_function): mock_function.side_effect = [ {"data": {"kvstores": [{"value": "https://example.com"}]}}, @@ -262,7 +267,7 @@ def test_get_file_url_and_verify_hash_without_account(self, mock_function): self.assertEqual(result, "https://example.com") - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_file_url_and_verify_hash_invalid_hash(self, mock_function): mock_function.side_effect = [ {"data": {"kvstores": [{"value": "https://example.com"}]}}, @@ -279,7 +284,7 @@ def test_get_file_url_and_verify_hash_invalid_hash(self, mock_function): KVStoreUtils.get_file_url_and_verify_hash(ChainId.LOCALHOST, address) self.assertEqual("Invalid hash", str(cm.exception)) - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_public_key(self, mock_function): mock_function.side_effect = [ {"data": {"kvstores": [{"value": "PUBLIC_KEY_URL"}]}}, @@ -302,7 +307,7 @@ def test_get_public_key_invalid_address(self): KVStoreUtils.get_public_key(ChainId.LOCALHOST, address) self.assertEqual(f"Invalid address: {address}", str(cm.exception)) - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_public_key_empty_value(self, mock_function): mock_function.return_value = {"data": {"kvstores": []}} @@ -316,7 +321,7 @@ def test_get_public_key_empty_value(self, mock_function): f"Key '{key}' not found for address {address}", str(cm.exception) ) - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_public_key_invalid_hash(self, mock_function): mock_function.side_effect = [ {"data": {"kvstores": [{"value": "PUBLIC_KEY_URL"}]}}, @@ -333,7 +338,7 @@ def test_get_public_key_invalid_hash(self, mock_function): KVStoreUtils.get_public_key(ChainId.LOCALHOST, address) self.assertEqual("Invalid hash", str(cm.exception)) - @patch("human_protocol_sdk.kvstore.kvstore_utils.get_data_from_subgraph") + @patch("human_protocol_sdk.kvstore.kvstore_utils.custom_gql_fetch") def test_get_public_key_without_account(self, mock_function): mock_function.side_effect = [ {"data": {"kvstores": [{"value": "PUBLIC_KEY_URL"}]}}, diff --git a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/operator/test_operator_utils.py b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/operator/test_operator_utils.py index b77c23db86..126ddda3e6 100644 --- a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/operator/test_operator_utils.py +++ b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/operator/test_operator_utils.py @@ -18,7 +18,7 @@ def test_get_operators(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -67,6 +67,7 @@ def test_get_operators(self): "first": filter.first, "skip": filter.skip, }, + options=None, ) self.assertEqual(len(operators), 1) @@ -96,7 +97,7 @@ def test_get_operators_when_job_types_is_none(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -143,6 +144,7 @@ def test_get_operators_when_job_types_is_none(self): "first": filter.first, "skip": filter.skip, }, + options=None, ) self.assertEqual(len(operators), 1) @@ -172,7 +174,7 @@ def test_get_operators_when_job_types_is_array(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -219,6 +221,7 @@ def test_get_operators_when_job_types_is_array(self): "first": filter.first, "skip": filter.skip, }, + options=None, ) self.assertEqual(len(operators), 1) @@ -248,7 +251,7 @@ def test_get_operators_empty_data(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = [ { @@ -271,6 +274,7 @@ def test_get_operators_empty_data(self): "first": filter.first, "skip": filter.skip, }, + options=None, ) self.assertEqual(operators, []) @@ -281,7 +285,7 @@ def test_get_operator(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -321,6 +325,7 @@ def test_get_operator(self): NETWORKS[ChainId.POLYGON], query=get_operator_query, params={"address": staker_address}, + options=None, ) self.assertNotEqual(operator, None) @@ -351,7 +356,7 @@ def test_get_operator_when_job_types_is_none(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -389,6 +394,7 @@ def test_get_operator_when_job_types_is_none(self): NETWORKS[ChainId.POLYGON], query=get_operator_query, params={"address": staker_address}, + options=None, ) self.assertNotEqual(operator, None) @@ -419,7 +425,7 @@ def test_get_operator_when_job_types_is_array(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -457,6 +463,7 @@ def test_get_operator_when_job_types_is_array(self): NETWORKS[ChainId.POLYGON], query=get_operator_query, params={"address": staker_address}, + options=None, ) self.assertNotEqual(operator, None) @@ -485,7 +492,7 @@ def test_get_operator_empty_data(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = [{"data": {"operator": None}}] @@ -495,6 +502,7 @@ def test_get_operator_empty_data(self): NETWORKS[ChainId.POLYGON], query=get_operator_query, params={"address": staker_address}, + options=None, ) self.assertEqual(operator, None) @@ -509,7 +517,7 @@ def test_get_reputation_network_operators(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -541,6 +549,7 @@ def test_get_reputation_network_operators(self): NETWORKS[ChainId.POLYGON], query=get_reputation_network_query(None), params={"address": reputation_address, "role": None}, + options=None, ) self.assertNotEqual(operators, []) @@ -561,7 +570,7 @@ def test_get_reputation_network_operators_when_job_types_is_none(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -593,6 +602,7 @@ def test_get_reputation_network_operators_when_job_types_is_none(self): NETWORKS[ChainId.POLYGON], query=get_reputation_network_query(None), params={"address": reputation_address, "role": None}, + options=None, ) self.assertNotEqual(operators, []) @@ -613,7 +623,7 @@ def test_get_reputation_network_operators_when_job_types_is_array(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -645,6 +655,7 @@ def test_get_reputation_network_operators_when_job_types_is_array(self): NETWORKS[ChainId.POLYGON], query=get_reputation_network_query(None), params={"address": reputation_address, "role": None}, + options=None, ) self.assertNotEqual(operators, []) @@ -661,7 +672,7 @@ def test_get_reputation_network_operators_empty_data(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = [{"data": {"reputationNetwork": None}}] @@ -673,6 +684,7 @@ def test_get_reputation_network_operators_empty_data(self): NETWORKS[ChainId.POLYGON], query=get_reputation_network_query(None), params={"address": reputation_address, "role": None}, + options=None, ) self.assertEqual(operators, []) @@ -682,7 +694,7 @@ def test_get_rewards_info(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = { "data": { @@ -704,6 +716,7 @@ def test_get_rewards_info(self): NETWORKS[ChainId.POLYGON], query=get_reward_added_events_query, params={"slasherAddress": slasher}, + options=None, ) self.assertEqual(len(rewards_info), 2) @@ -717,7 +730,7 @@ def test_get_rewards_info_empty_data(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.operator.operator_utils.get_data_from_subgraph" + "human_protocol_sdk.operator.operator_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = {"data": {"rewardAddedEvents": None}} rewards_info = OperatorUtils.get_rewards_info(ChainId.POLYGON, slasher) @@ -726,6 +739,7 @@ def test_get_rewards_info_empty_data(self): NETWORKS[ChainId.POLYGON], query=get_reward_added_events_query, params={"slasherAddress": slasher}, + options=None, ) self.assertEqual(rewards_info, []) diff --git a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/staking/test_staking_utils.py b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/staking/test_staking_utils.py index 0f9183eb8b..18d8aa0b40 100644 --- a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/staking/test_staking_utils.py +++ b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/staking/test_staking_utils.py @@ -14,7 +14,7 @@ class TestStakingUtils(unittest.TestCase): def test_get_stakers(self): with patch( - "human_protocol_sdk.staking.staking_utils.get_data_from_subgraph" + "human_protocol_sdk.staking.staking_utils.custom_gql_fetch" ) as mock_function: mock_staker_1 = { "id": "1", @@ -69,6 +69,7 @@ def test_get_stakers(self): "first": 2, "skip": 0, }, + options=None, ) self.assertEqual(len(stakers), 2) self.assertIsInstance(stakers[0], StakerData) @@ -120,7 +121,7 @@ def test_get_stakers(self): def test_get_stakers_empty_response(self): with patch( - "human_protocol_sdk.staking.staking_utils.get_data_from_subgraph" + "human_protocol_sdk.staking.staking_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = {"data": {"stakers": []}} @@ -139,7 +140,7 @@ def test_get_stakers_invalid_network(self): def test_get_staker(self): with patch( - "human_protocol_sdk.staking.staking_utils.get_data_from_subgraph" + "human_protocol_sdk.staking.staking_utils.custom_gql_fetch" ) as mock_function: mock_staker = { "id": "1", @@ -160,6 +161,7 @@ def test_get_staker(self): NETWORKS[ChainId.POLYGON_AMOY], query=get_staker_query(), params={"id": "0x123"}, + options=None, ) self.assertIsInstance(staker, StakerData) self.assertEqual(staker.id, "1") @@ -181,7 +183,7 @@ def test_get_staker(self): def test_get_staker_empty_data(self): with patch( - "human_protocol_sdk.staking.staking_utils.get_data_from_subgraph" + "human_protocol_sdk.staking.staking_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = {"data": {"staker": None}} diff --git a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/statistics/test_statistics_client.py b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/statistics/test_statistics_client.py index 666dc1029d..2299d296cf 100644 --- a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/statistics/test_statistics_client.py +++ b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/statistics/test_statistics_client.py @@ -33,7 +33,7 @@ def test_get_escrow_statistics(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.statistics.statistics_client.get_data_from_subgraph" + "human_protocol_sdk.statistics.statistics_client.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -65,6 +65,7 @@ def test_get_escrow_statistics(self): mock_function.assert_any_call( NETWORKS[ChainId.LOCALHOST], query=get_escrow_statistics_query, + options=None, ) mock_function.assert_any_call( @@ -77,6 +78,7 @@ def test_get_escrow_statistics(self): "skip": 0, "orderDirection": "asc", }, + options=None, ) self.assertEqual(escrow_statistics.total_escrows, 1) @@ -101,7 +103,7 @@ def test_get_worker_statistics(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.statistics.statistics_client.get_data_from_subgraph" + "human_protocol_sdk.statistics.statistics_client.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -128,6 +130,7 @@ def test_get_worker_statistics(self): "skip": 0, "orderDirection": "asc", }, + options=None, ) self.assertEqual(len(payment_statistics.daily_workers_data), 1) @@ -144,7 +147,7 @@ def test_get_payment_statistics(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.statistics.statistics_client.get_data_from_subgraph" + "human_protocol_sdk.statistics.statistics_client.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -173,6 +176,7 @@ def test_get_payment_statistics(self): "skip": 0, "orderDirection": "asc", }, + options=None, ) self.assertEqual(len(payment_statistics.daily_payments_data), 1) @@ -192,7 +196,7 @@ def test_get_hmt_statistics(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.statistics.statistics_client.get_data_from_subgraph" + "human_protocol_sdk.statistics.statistics_client.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -211,6 +215,7 @@ def test_get_hmt_statistics(self): mock_function.assert_any_call( NETWORKS[ChainId.LOCALHOST], query=get_hmtoken_statistics_query, + options=None, ) self.assertEqual(hmt_statistics.total_transfer_amount, 100) @@ -225,7 +230,7 @@ def test_get_hmt_holders(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.statistics.statistics_client.get_data_from_subgraph" + "human_protocol_sdk.statistics.statistics_client.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -250,6 +255,7 @@ def test_get_hmt_holders(self): "orderBy": "balance", "orderDirection": param.order_direction, }, + options=None, ) self.assertEqual(len(holders), 2) @@ -266,7 +272,7 @@ def test_get_hmt_daily_data(self): mock_function = MagicMock() with patch( - "human_protocol_sdk.statistics.statistics_client.get_data_from_subgraph" + "human_protocol_sdk.statistics.statistics_client.custom_gql_fetch" ) as mock_function: mock_function.side_effect = [ { @@ -296,6 +302,7 @@ def test_get_hmt_daily_data(self): "skip": 0, "orderDirection": "asc", }, + options=None, ) self.assertEqual(len(hmt_statistics), 1) diff --git a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/test_utils.py b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/test_utils.py index 1aea57fb90..cb41e620ef 100644 --- a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/test_utils.py +++ b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/test_utils.py @@ -1,7 +1,21 @@ import unittest +from unittest.mock import Mock, patch from validators import ValidationError -from human_protocol_sdk.utils import validate_url +from human_protocol_sdk.utils import ( + SubgraphOptions, + custom_gql_fetch, + is_indexer_error, + validate_url, +) + + +def make_graphql_error(payload, message=""): + error = Exception(message or "GraphQL error") + response = Mock() + response.json.return_value = payload + error.response = response + return error class TestStorageClient(unittest.TestCase): @@ -12,4 +26,103 @@ def test_validate_url_with_docker_network_url(self): self.assertTrue(validate_url("http://test:8000/valid")) def test_validate_url_with_invalid_url(self): - assert isinstance(validate_url("htt://test:8000/valid"), ValidationError) + self.assertIsInstance(validate_url("htt://test:8000/valid"), ValidationError) + + +class TestIsIndexerError(unittest.TestCase): + def test_returns_true_for_graphql_response(self): + error = make_graphql_error( + {"errors": [{"message": "bad indexers: out of sync"}]} + ) + self.assertTrue(is_indexer_error(error)) + + def test_returns_true_for_message(self): + error = Exception("bad indexers: [...]") + self.assertTrue(is_indexer_error(error)) + + def test_returns_false_when_not_indexer_error(self): + error = Exception("some other issue") + self.assertFalse(is_indexer_error(error)) + + +class TestGetDataFromSubgraph(unittest.TestCase): + def setUp(self): + self.network = { + "subgraph_url": "http://subgraph", + "subgraph_url_api_key": "http://subgraph-with-key", + } + self.query = "query Test" + self.variables = {"foo": "bar"} + + def test_returns_response_without_options(self): + expected = {"data": {"ok": True}} + with patch( + "human_protocol_sdk.utils._fetch_subgraph_data", + return_value=expected, + ) as mock_fetch: + result = custom_gql_fetch(self.network, self.query, self.variables) + + self.assertEqual(result, expected) + mock_fetch.assert_called_once_with(self.network, self.query, self.variables) + + def test_retries_on_indexer_error_and_succeeds(self): + options = SubgraphOptions(max_retries=2, base_delay=100) + error = make_graphql_error({"errors": [{"message": "Bad indexers: syncing"}]}) + + with patch( + "human_protocol_sdk.utils._fetch_subgraph_data", + side_effect=[error, {"data": {"ok": True}}], + ) as mock_fetch, patch("human_protocol_sdk.utils.time.sleep") as mock_sleep: + result = custom_gql_fetch( + self.network, self.query, self.variables, options=options + ) + + self.assertEqual(result, {"data": {"ok": True}}) + self.assertEqual(mock_fetch.call_count, 2) + mock_sleep.assert_called_once() + + def test_raises_when_retry_options_incomplete(self): + options = SubgraphOptions(max_retries=2) + + with patch("human_protocol_sdk.utils._fetch_subgraph_data") as mock_fetch: + with self.assertRaises(ValueError) as ctx: + custom_gql_fetch( + self.network, self.query, self.variables, options=options + ) + + self.assertIn("max_retries", str(ctx.exception)) + mock_fetch.assert_not_called() + + def test_raises_immediately_on_non_indexer_error(self): + options = SubgraphOptions(max_retries=3, base_delay=50) + with patch( + "human_protocol_sdk.utils._fetch_subgraph_data", + side_effect=Exception("network failure"), + ) as mock_fetch, patch("human_protocol_sdk.utils.time.sleep") as mock_sleep: + with self.assertRaises(Exception) as ctx: + custom_gql_fetch( + self.network, self.query, self.variables, options=options + ) + + self.assertIn("network failure", str(ctx.exception)) + mock_fetch.assert_called_once() + mock_sleep.assert_not_called() + + def test_raises_after_exhausting_retries(self): + options = SubgraphOptions(max_retries=2, base_delay=10) + errors = [ + make_graphql_error({"errors": [{"message": "bad indexers: stalled"}]}) + for _ in range(3) + ] + + with patch( + "human_protocol_sdk.utils._fetch_subgraph_data", + side_effect=errors, + ) as mock_fetch, patch("human_protocol_sdk.utils.time.sleep"): + with self.assertRaises(Exception) as ctx: + custom_gql_fetch( + self.network, self.query, self.variables, options=options + ) + + self.assertTrue(is_indexer_error(ctx.exception)) + self.assertEqual(mock_fetch.call_count, 3) diff --git a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/transaction/test_transaction_utils.py b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/transaction/test_transaction_utils.py index aee4e3914f..d475b63ae1 100644 --- a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/transaction/test_transaction_utils.py +++ b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/transaction/test_transaction_utils.py @@ -16,7 +16,7 @@ class TestTransactionUtils(unittest.TestCase): def test_get_transactions(self): with patch( - "human_protocol_sdk.transaction.transaction_utils.get_data_from_subgraph" + "human_protocol_sdk.transaction.transaction_utils.custom_gql_fetch" ) as mock_function: mock_transaction_1 = { "block": "123", @@ -82,6 +82,7 @@ def test_get_transactions(self): "token": None, "method": None, }, + options=None, ) self.assertEqual(len(transactions), 2) self.assertEqual(transactions[0].chain_id, ChainId.POLYGON_AMOY) @@ -89,7 +90,7 @@ def test_get_transactions(self): def test_get_transactions_empty_response(self): with patch( - "human_protocol_sdk.transaction.transaction_utils.get_data_from_subgraph" + "human_protocol_sdk.transaction.transaction_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = {"data": {"transactions": []}} @@ -117,6 +118,7 @@ def test_get_transactions_empty_response(self): "token": None, "method": None, }, + options=None, ) self.assertEqual(len(transactions), 0) @@ -148,7 +150,7 @@ def test_get_transactions_invalid_to_address(self): def test_get_transaction(self): with patch( - "human_protocol_sdk.transaction.transaction_utils.get_data_from_subgraph" + "human_protocol_sdk.transaction.transaction_utils.custom_gql_fetch" ) as mock_function: mock_transaction = { "block": "123", @@ -181,6 +183,7 @@ def test_get_transaction(self): params={ "hash": "0x1234567890123456789012345678901234567890123456789012345678901234" }, + options=None, ) self.assertIsNotNone(transaction) self.assertEqual(transaction.chain_id, ChainId.POLYGON_AMOY) @@ -196,7 +199,7 @@ def test_get_transaction(self): def test_get_transaction_empty_data(self): with patch( - "human_protocol_sdk.transaction.transaction_utils.get_data_from_subgraph" + "human_protocol_sdk.transaction.transaction_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = {"data": {"transaction": None}} @@ -209,6 +212,7 @@ def test_get_transaction_empty_data(self): NETWORKS[ChainId.POLYGON_AMOY], query=ANY, params={"hash": "transaction_hash"}, + options=None, ) self.assertIsNone(transaction) diff --git a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/worker/test_worker_utils.py b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/worker/test_worker_utils.py index 4427af4998..ea44241c19 100644 --- a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/worker/test_worker_utils.py +++ b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/worker/test_worker_utils.py @@ -10,7 +10,7 @@ class TestWorkerUtils(unittest.TestCase): def test_get_workers(self): with patch( - "human_protocol_sdk.worker.worker_utils.get_data_from_subgraph" + "human_protocol_sdk.worker.worker_utils.custom_gql_fetch" ) as mock_function: mock_worker_1 = { "id": "worker1", @@ -47,6 +47,7 @@ def test_get_workers(self): "orderBy": "totalHMTAmountReceived", "orderDirection": "asc", }, + options=None, ) self.assertEqual(len(workers), 2) self.assertEqual(workers[0].id, "worker1") @@ -54,7 +55,7 @@ def test_get_workers(self): def test_get_workers_empty_response(self): with patch( - "human_protocol_sdk.worker.worker_utils.get_data_from_subgraph" + "human_protocol_sdk.worker.worker_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = {"data": {"workers": []}} @@ -75,6 +76,7 @@ def test_get_workers_empty_response(self): "orderBy": "payoutCount", "orderDirection": "desc", }, + options=None, ) self.assertEqual(len(workers), 0) @@ -86,7 +88,7 @@ def test_get_workers_invalid_network(self): def test_get_worker(self): with patch( - "human_protocol_sdk.worker.worker_utils.get_data_from_subgraph" + "human_protocol_sdk.worker.worker_utils.custom_gql_fetch" ) as mock_function: mock_worker = { "id": "worker1", @@ -106,6 +108,7 @@ def test_get_worker(self): NETWORKS[ChainId.POLYGON_AMOY], query=get_worker_query(), params={"address": "0x1234567890123456789012345678901234567890"}, + options=None, ) self.assertIsNotNone(worker) self.assertEqual(worker.id, "worker1") @@ -115,7 +118,7 @@ def test_get_worker(self): def test_get_worker_empty_data(self): with patch( - "human_protocol_sdk.worker.worker_utils.get_data_from_subgraph" + "human_protocol_sdk.worker.worker_utils.custom_gql_fetch" ) as mock_function: mock_function.return_value = {"data": {"worker": None}} @@ -127,6 +130,7 @@ def test_get_worker_empty_data(self): NETWORKS[ChainId.POLYGON_AMOY], query=get_worker_query(), params={"address": "0x1234567890123456789012345678901234567890"}, + options=None, ) self.assertIsNone(worker) diff --git a/packages/sdk/typescript/human-protocol-sdk/src/error.ts b/packages/sdk/typescript/human-protocol-sdk/src/error.ts index a4c4d9fae7..40af09be57 100644 --- a/packages/sdk/typescript/human-protocol-sdk/src/error.ts +++ b/packages/sdk/typescript/human-protocol-sdk/src/error.ts @@ -306,6 +306,13 @@ export const ErrorBulkPayOutVersion = new Error( 'Invalid bulkPayOut parameters for the contract version of the specified escrow address' ); +/** + * @constant {Error} - Retry configuration is missing required parameters. + */ +export const ErrorRetryParametersMissing = new Error( + 'Retry configuration must include both maxRetries and baseDelay' +); + /** * @constant {Warning} - Possible version mismatch. */ diff --git a/packages/sdk/typescript/human-protocol-sdk/src/escrow.ts b/packages/sdk/typescript/human-protocol-sdk/src/escrow.ts index cf1f946b96..77661805d8 100644 --- a/packages/sdk/typescript/human-protocol-sdk/src/escrow.ts +++ b/packages/sdk/typescript/human-protocol-sdk/src/escrow.ts @@ -10,7 +10,6 @@ import { HMToken__factory, } from '@human-protocol/core/typechain-types'; import { ContractRunner, EventLog, Overrides, Signer, ethers } from 'ethers'; -import gqlFetch from 'graphql-request'; import { BaseEthersClient } from './base'; import { ESCROW_BULK_PAYOUT_MAX_ITEMS, NETWORKS } from './constants'; import { requiresSigner } from './decorators'; @@ -62,13 +61,16 @@ import { IStatusEventFilter, IStatusEvent, ICancellationRefund, + ICancellationRefundFilter, IPayout, IEscrowWithdraw, + SubgraphOptions, } from './interfaces'; import { EscrowStatus, NetworkData, TransactionLikeWithNonce } from './types'; import { getSubgraphUrl, getUnixTimestamp, + customGqlFetch, isValidJson, isValidUrl, throwError, @@ -1940,6 +1942,7 @@ export class EscrowUtils { * * * @param {IEscrowsFilter} filter Filter parameters. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {IEscrow[]} List of escrows that match the filter. * * **Code example** @@ -1956,7 +1959,10 @@ export class EscrowUtils { * const escrows = await EscrowUtils.getEscrows(filters); * ``` */ - public static async getEscrows(filter: IEscrowsFilter): Promise { + public static async getEscrows( + filter: IEscrowsFilter, + options?: SubgraphOptions + ): Promise { if (filter.launcher && !ethers.isAddress(filter.launcher)) { throw ErrorInvalidAddress; } @@ -1989,7 +1995,7 @@ export class EscrowUtils { statuses = Array.isArray(filter.status) ? filter.status : [filter.status]; statuses = statuses.map((status) => EscrowStatus[status]); } - const { escrows } = await gqlFetch<{ escrows: EscrowData[] }>( + const { escrows } = await customGqlFetch<{ escrows: EscrowData[] }>( getSubgraphUrl(networkData), GET_ESCROWS_QUERY(filter), { @@ -2004,7 +2010,8 @@ export class EscrowUtils { orderDirection: orderDirection, first: first, skip: skip, - } + }, + options ); return (escrows || []).map((e) => mapEscrow(e, networkData.chainId)); } @@ -2062,6 +2069,7 @@ export class EscrowUtils { * * @param {ChainId} chainId Network in which the escrow has been deployed * @param {string} escrowAddress Address of the escrow + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} - Escrow data or null if not found. * * **Code example** @@ -2074,7 +2082,8 @@ export class EscrowUtils { */ public static async getEscrow( chainId: ChainId, - escrowAddress: string + escrowAddress: string, + options?: SubgraphOptions ): Promise { const networkData = NETWORKS[chainId]; @@ -2086,10 +2095,11 @@ export class EscrowUtils { throw ErrorInvalidAddress; } - const { escrow } = await gqlFetch<{ escrow: EscrowData | null }>( + const { escrow } = await customGqlFetch<{ escrow: EscrowData | null }>( getSubgraphUrl(networkData), GET_ESCROW_BY_ADDRESS_QUERY(), - { escrowAddress: escrowAddress.toLowerCase() } + { escrowAddress: escrowAddress.toLowerCase() }, + options ); if (!escrow) return null; @@ -2132,6 +2142,7 @@ export class EscrowUtils { * ``` * * @param {IStatusEventFilter} filter Filter parameters. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} - Array of status events with their corresponding statuses. * * **Code example** @@ -2153,7 +2164,8 @@ export class EscrowUtils { * ``` */ public static async getStatusEvents( - filter: IStatusEventFilter + filter: IStatusEventFilter, + options?: SubgraphOptions ): Promise { const { chainId, @@ -2187,7 +2199,7 @@ export class EscrowUtils { const statusNames = effectiveStatuses.map((status) => EscrowStatus[status]); - const data = await gqlFetch<{ + const data = await customGqlFetch<{ escrowStatusEvents: StatusEvent[]; }>( getSubgraphUrl(networkData), @@ -2200,7 +2212,8 @@ export class EscrowUtils { orderDirection, first: Math.min(first, 1000), skip, - } + }, + options ); if (!data || !data['escrowStatusEvents']) { @@ -2224,6 +2237,7 @@ export class EscrowUtils { * Fetch payouts from the subgraph. * * @param {IPayoutFilter} filter Filter parameters. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} List of payouts matching the filters. * * **Code example** @@ -2241,7 +2255,10 @@ export class EscrowUtils { * console.log(payouts); * ``` */ - public static async getPayouts(filter: IPayoutFilter): Promise { + public static async getPayouts( + filter: IPayoutFilter, + options?: SubgraphOptions + ): Promise { const networkData = NETWORKS[filter.chainId]; if (!networkData) { throw ErrorUnsupportedChainID; @@ -2258,7 +2275,7 @@ export class EscrowUtils { const skip = filter.skip || 0; const orderDirection = filter.orderDirection || OrderDirection.DESC; - const { payouts } = await gqlFetch<{ payouts: PayoutData[] }>( + const { payouts } = await customGqlFetch<{ payouts: PayoutData[] }>( getSubgraphUrl(networkData), GET_PAYOUTS_QUERY(filter), { @@ -2269,7 +2286,8 @@ export class EscrowUtils { first: Math.min(first, 1000), skip, orderDirection, - } + }, + options ); if (!payouts) { return []; @@ -2318,6 +2336,7 @@ export class EscrowUtils { * * * @param {Object} filter Filter parameters. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} List of cancellation refunds matching the filters. * * **Code example** @@ -2332,16 +2351,10 @@ export class EscrowUtils { * console.log(cancellationRefunds); * ``` */ - public static async getCancellationRefunds(filter: { - chainId: ChainId; - escrowAddress?: string; - receiver?: string; - from?: Date; - to?: Date; - first?: number; - skip?: number; - orderDirection?: OrderDirection; - }): Promise { + public static async getCancellationRefunds( + filter: ICancellationRefundFilter, + options?: SubgraphOptions + ): Promise { const networkData = NETWORKS[filter.chainId]; if (!networkData) throw ErrorUnsupportedChainID; if (filter.escrowAddress && !ethers.isAddress(filter.escrowAddress)) { @@ -2356,17 +2369,22 @@ export class EscrowUtils { const skip = filter.skip || 0; const orderDirection = filter.orderDirection || OrderDirection.DESC; - const { cancellationRefundEvents } = await gqlFetch<{ + const { cancellationRefundEvents } = await customGqlFetch<{ cancellationRefundEvents: CancellationRefundData[]; - }>(getSubgraphUrl(networkData), GET_CANCELLATION_REFUNDS_QUERY(filter), { - escrowAddress: filter.escrowAddress?.toLowerCase(), - receiver: filter.receiver?.toLowerCase(), - from: filter.from ? getUnixTimestamp(filter.from) : undefined, - to: filter.to ? getUnixTimestamp(filter.to) : undefined, - first, - skip, - orderDirection, - }); + }>( + getSubgraphUrl(networkData), + GET_CANCELLATION_REFUNDS_QUERY(filter), + { + escrowAddress: filter.escrowAddress?.toLowerCase(), + receiver: filter.receiver?.toLowerCase(), + from: filter.from ? getUnixTimestamp(filter.from) : undefined, + to: filter.to ? getUnixTimestamp(filter.to) : undefined, + first, + skip, + orderDirection, + }, + options + ); if (!cancellationRefundEvents || cancellationRefundEvents.length === 0) { return []; @@ -2418,6 +2436,7 @@ export class EscrowUtils { * * @param {ChainId} chainId Network in which the escrow has been deployed * @param {string} escrowAddress Address of the escrow + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} Cancellation refund data * * **Code example** @@ -2430,7 +2449,8 @@ export class EscrowUtils { */ public static async getCancellationRefund( chainId: ChainId, - escrowAddress: string + escrowAddress: string, + options?: SubgraphOptions ): Promise { const networkData = NETWORKS[chainId]; if (!networkData) throw ErrorUnsupportedChainID; @@ -2439,12 +2459,13 @@ export class EscrowUtils { throw ErrorInvalidEscrowAddressProvided; } - const { cancellationRefundEvents } = await gqlFetch<{ + const { cancellationRefundEvents } = await customGqlFetch<{ cancellationRefundEvents: CancellationRefundData[]; }>( getSubgraphUrl(networkData), GET_CANCELLATION_REFUND_BY_ADDRESS_QUERY(), - { escrowAddress: escrowAddress.toLowerCase() } + { escrowAddress: escrowAddress.toLowerCase() }, + options ); if (!cancellationRefundEvents || cancellationRefundEvents.length === 0) { diff --git a/packages/sdk/typescript/human-protocol-sdk/src/interfaces.ts b/packages/sdk/typescript/human-protocol-sdk/src/interfaces.ts index c47656c690..9a204751ce 100644 --- a/packages/sdk/typescript/human-protocol-sdk/src/interfaces.ts +++ b/packages/sdk/typescript/human-protocol-sdk/src/interfaces.ts @@ -312,3 +312,13 @@ export interface IEscrowWithdraw { tokenAddress: string; withdrawnAmount: bigint; } + +/** + * Configuration options for subgraph requests with retry logic. + */ +export interface SubgraphOptions { + /** Maximum number of retry attempts */ + maxRetries?: number; + /** Base delay between retries in milliseconds */ + baseDelay?: number; +} diff --git a/packages/sdk/typescript/human-protocol-sdk/src/kvstore.ts b/packages/sdk/typescript/human-protocol-sdk/src/kvstore.ts index e2378ba70e..6a8bbaa212 100644 --- a/packages/sdk/typescript/human-protocol-sdk/src/kvstore.ts +++ b/packages/sdk/typescript/human-protocol-sdk/src/kvstore.ts @@ -17,15 +17,14 @@ import { ErrorUnsupportedChainID, InvalidKeyError, } from './error'; -import gqlFetch from 'graphql-request'; import { NetworkData } from './types'; -import { getSubgraphUrl, isValidUrl } from './utils'; +import { getSubgraphUrl, customGqlFetch, isValidUrl } from './utils'; import { GET_KVSTORE_BY_ADDRESS_AND_KEY_QUERY, GET_KVSTORE_BY_ADDRESS_QUERY, } from './graphql/queries/kvstore'; import { KVStoreData } from './graphql'; -import { IKVStore } from './interfaces'; +import { IKVStore, SubgraphOptions } from './interfaces'; /** * ## Introduction * @@ -294,6 +293,7 @@ export class KVStoreClient extends BaseEthersClient { * * @param {string} address Address from which to get the key value. * @param {string} key Key to obtain the value. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {string} Value of the key. * * @@ -365,6 +365,7 @@ export class KVStoreUtils { * * @param {ChainId} chainId Network in which the KVStore is deployed * @param {string} address Address of the KVStore + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} KVStore data * @throws {ErrorUnsupportedChainID} - Thrown if the network's chainId is not supported * @throws {ErrorInvalidAddress} - Thrown if the Address sent is invalid @@ -380,7 +381,8 @@ export class KVStoreUtils { */ public static async getKVStoreData( chainId: ChainId, - address: string + address: string, + options?: SubgraphOptions ): Promise { const networkData = NETWORKS[chainId]; @@ -392,10 +394,11 @@ export class KVStoreUtils { throw ErrorInvalidAddress; } - const { kvstores } = await gqlFetch<{ kvstores: KVStoreData[] }>( + const { kvstores } = await customGqlFetch<{ kvstores: KVStoreData[] }>( getSubgraphUrl(networkData), GET_KVSTORE_BY_ADDRESS_QUERY(), - { address: address.toLowerCase() } + { address: address.toLowerCase() }, + options ); const kvStoreData = kvstores.map((item) => ({ @@ -412,6 +415,7 @@ export class KVStoreUtils { * @param {ChainId} chainId Network in which the KVStore is deployed * @param {string} address Address from which to get the key value. * @param {string} key Key to obtain the value. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} Value of the key. * @throws {ErrorUnsupportedChainID} - Thrown if the network's chainId is not supported * @throws {ErrorInvalidAddress} - Thrown if the Address sent is invalid @@ -433,7 +437,8 @@ export class KVStoreUtils { public static async get( chainId: ChainId, address: string, - key: string + key: string, + options?: SubgraphOptions ): Promise { if (key === '') throw ErrorKVStoreEmptyKey; if (!ethers.isAddress(address)) throw ErrorInvalidAddress; @@ -444,10 +449,11 @@ export class KVStoreUtils { throw ErrorUnsupportedChainID; } - const { kvstores } = await gqlFetch<{ kvstores: KVStoreData[] }>( + const { kvstores } = await customGqlFetch<{ kvstores: KVStoreData[] }>( getSubgraphUrl(networkData), GET_KVSTORE_BY_ADDRESS_AND_KEY_QUERY(), - { address: address.toLowerCase(), key } + { address: address.toLowerCase(), key }, + options ); if (!kvstores || kvstores.length === 0) { @@ -463,6 +469,7 @@ export class KVStoreUtils { * @param {ChainId} chainId Network in which the KVStore is deployed * @param {string} address Address from which to get the URL value. * @param {string} urlKey Configurable URL key. `url` by default. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} URL value for the given address if it exists, and the content is valid * * **Code example** @@ -480,7 +487,8 @@ export class KVStoreUtils { public static async getFileUrlAndVerifyHash( chainId: ChainId, address: string, - urlKey = 'url' + urlKey = 'url', + options?: SubgraphOptions ): Promise { if (!ethers.isAddress(address)) throw ErrorInvalidAddress; const hashKey = urlKey + '_hash'; @@ -489,7 +497,7 @@ export class KVStoreUtils { hash = ''; try { - url = await this.get(chainId, address, urlKey); + url = await this.get(chainId, address, urlKey, options); } catch (e) { if (e instanceof Error) throw Error(`Failed to get URL: ${e.message}`); } @@ -539,12 +547,14 @@ export class KVStoreUtils { */ public static async getPublicKey( chainId: ChainId, - address: string + address: string, + options?: SubgraphOptions ): Promise { const publicKeyUrl = await this.getFileUrlAndVerifyHash( chainId, address, - KVStoreKeys.publicKey + KVStoreKeys.publicKey, + options ); if (publicKeyUrl === '') { diff --git a/packages/sdk/typescript/human-protocol-sdk/src/operator.ts b/packages/sdk/typescript/human-protocol-sdk/src/operator.ts index cbca6e866a..2428c9d24e 100644 --- a/packages/sdk/typescript/human-protocol-sdk/src/operator.ts +++ b/packages/sdk/typescript/human-protocol-sdk/src/operator.ts @@ -1,6 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import gqlFetch from 'graphql-request'; -import { IOperator, IOperatorsFilter, IReward } from './interfaces'; +import { + IOperator, + IOperatorsFilter, + IReward, + SubgraphOptions, +} from './interfaces'; import { GET_REWARD_ADDED_EVENTS_QUERY } from './graphql/queries/reward'; import { IOperatorSubgraph, @@ -18,7 +22,7 @@ import { ErrorInvalidStakerAddressProvided, ErrorUnsupportedChainID, } from './error'; -import { getSubgraphUrl } from './utils'; +import { getSubgraphUrl, customGqlFetch } from './utils'; import { ChainId, OrderDirection } from './enums'; import { NETWORKS } from './constants'; @@ -28,6 +32,7 @@ export class OperatorUtils { * * @param {ChainId} chainId Network in which the operator is deployed * @param {string} address Operator address. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} - Returns the operator details or null if not found. * * **Code example** @@ -40,7 +45,8 @@ export class OperatorUtils { */ public static async getOperator( chainId: ChainId, - address: string + address: string, + options?: SubgraphOptions ): Promise { if (!ethers.isAddress(address)) { throw ErrorInvalidStakerAddressProvided; @@ -51,10 +57,11 @@ export class OperatorUtils { throw ErrorUnsupportedChainID; } - const { operator } = await gqlFetch<{ + const { operator } = await customGqlFetch<{ operator: IOperatorSubgraph; }>(getSubgraphUrl(networkData), GET_LEADER_QUERY, { address: address.toLowerCase(), + options, }); if (!operator) { @@ -68,6 +75,7 @@ export class OperatorUtils { * This function returns all the operator details of the protocol. * * @param {IOperatorsFilter} filter Filter for the operators. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} Returns an array with all the operator details. * * **Code example** @@ -82,7 +90,8 @@ export class OperatorUtils { * ``` */ public static async getOperators( - filter: IOperatorsFilter + filter: IOperatorsFilter, + options?: SubgraphOptions ): Promise { const first = filter.first !== undefined && filter.first > 0 @@ -107,16 +116,21 @@ export class OperatorUtils { throw ErrorUnsupportedChainID; } - const { operators } = await gqlFetch<{ + const { operators } = await customGqlFetch<{ operators: IOperatorSubgraph[]; - }>(getSubgraphUrl(networkData), GET_LEADERS_QUERY(filter), { - minStakedAmount: filter?.minStakedAmount, - roles: filter?.roles, - orderBy: orderBy, - orderDirection: orderDirection, - first: first, - skip: skip, - }); + }>( + getSubgraphUrl(networkData), + GET_LEADERS_QUERY(filter), + { + minStakedAmount: filter?.minStakedAmount, + roles: filter?.roles, + orderBy: orderBy, + orderDirection: orderDirection, + first: first, + skip: skip, + }, + options + ); if (!operators) { return []; @@ -131,6 +145,7 @@ export class OperatorUtils { * @param {ChainId} chainId Network in which the reputation network is deployed * @param {string} address Address of the reputation oracle. * @param {string} [role] - (Optional) Role of the operator. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} - Returns an array of operator details. * * **Code example** @@ -144,19 +159,25 @@ export class OperatorUtils { public static async getReputationNetworkOperators( chainId: ChainId, address: string, - role?: string + role?: string, + options?: SubgraphOptions ): Promise { const networkData = NETWORKS[chainId]; if (!networkData) { throw ErrorUnsupportedChainID; } - const { reputationNetwork } = await gqlFetch<{ + const { reputationNetwork } = await customGqlFetch<{ reputationNetwork: IReputationNetworkSubgraph; - }>(getSubgraphUrl(networkData), GET_REPUTATION_NETWORK_QUERY(role), { - address: address.toLowerCase(), - role: role, - }); + }>( + getSubgraphUrl(networkData), + GET_REPUTATION_NETWORK_QUERY(role), + { + address: address.toLowerCase(), + role: role, + }, + options + ); if (!reputationNetwork) return []; @@ -170,6 +191,7 @@ export class OperatorUtils { * * @param {ChainId} chainId Network in which the rewards are deployed * @param {string} slasherAddress Slasher address. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} Returns an array of Reward objects that contain the rewards earned by the user through slashing other users. * * **Code example** @@ -182,7 +204,8 @@ export class OperatorUtils { */ public static async getRewards( chainId: ChainId, - slasherAddress: string + slasherAddress: string, + options?: SubgraphOptions ): Promise { if (!ethers.isAddress(slasherAddress)) { throw ErrorInvalidSlasherAddressProvided; @@ -193,11 +216,16 @@ export class OperatorUtils { throw ErrorUnsupportedChainID; } - const { rewardAddedEvents } = await gqlFetch<{ + const { rewardAddedEvents } = await customGqlFetch<{ rewardAddedEvents: RewardAddedEventData[]; - }>(getSubgraphUrl(networkData), GET_REWARD_ADDED_EVENTS_QUERY, { - slasherAddress: slasherAddress.toLowerCase(), - }); + }>( + getSubgraphUrl(networkData), + GET_REWARD_ADDED_EVENTS_QUERY, + { + slasherAddress: slasherAddress.toLowerCase(), + }, + options + ); if (!rewardAddedEvents) return []; diff --git a/packages/sdk/typescript/human-protocol-sdk/src/staking.ts b/packages/sdk/typescript/human-protocol-sdk/src/staking.ts index 1fab0ed1fd..4e5afb61e3 100644 --- a/packages/sdk/typescript/human-protocol-sdk/src/staking.ts +++ b/packages/sdk/typescript/human-protocol-sdk/src/staking.ts @@ -7,7 +7,6 @@ import { Staking__factory, } from '@human-protocol/core/typechain-types'; import { ContractRunner, Overrides, ethers } from 'ethers'; -import gqlFetch from 'graphql-request'; import { BaseEthersClient } from './base'; import { NETWORKS } from './constants'; import { requiresSigner } from './decorators'; @@ -23,10 +22,15 @@ import { ErrorStakerNotFound, ErrorUnsupportedChainID, } from './error'; -import { IStaker, IStakersFilter, StakerInfo } from './interfaces'; +import { + IStaker, + IStakersFilter, + StakerInfo, + SubgraphOptions, +} from './interfaces'; import { StakerData } from './graphql'; import { NetworkData } from './types'; -import { getSubgraphUrl, throwError } from './utils'; +import { getSubgraphUrl, customGqlFetch, throwError } from './utils'; import { GET_STAKER_BY_ADDRESS_QUERY, GET_STAKERS_QUERY, @@ -495,11 +499,13 @@ export class StakingUtils { * * @param {ChainId} chainId Network in which the staking contract is deployed * @param {string} stakerAddress Address of the staker + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} Staker info from subgraph */ public static async getStaker( chainId: ChainId, - stakerAddress: string + stakerAddress: string, + options?: SubgraphOptions ): Promise { if (!ethers.isAddress(stakerAddress)) { throw ErrorInvalidStakerAddressProvided; @@ -510,10 +516,11 @@ export class StakingUtils { throw ErrorUnsupportedChainID; } - const { staker } = await gqlFetch<{ staker: StakerData }>( + const { staker } = await customGqlFetch<{ staker: StakerData }>( getSubgraphUrl(networkData), GET_STAKER_BY_ADDRESS_QUERY, - { id: stakerAddress.toLowerCase() } + { id: stakerAddress.toLowerCase() }, + options ); if (!staker) { @@ -526,9 +533,14 @@ export class StakingUtils { /** * Gets all stakers from the subgraph with filters, pagination and ordering. * + * @param {IStakersFilter} filter Stakers filter with pagination and ordering + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} Array of stakers */ - public static async getStakers(filter: IStakersFilter): Promise { + public static async getStakers( + filter: IStakersFilter, + options?: SubgraphOptions + ): Promise { const first = filter.first !== undefined ? Math.min(filter.first, 1000) : 10; const skip = filter.skip || 0; @@ -540,7 +552,7 @@ export class StakingUtils { throw ErrorUnsupportedChainID; } - const { stakers } = await gqlFetch<{ stakers: StakerData[] }>( + const { stakers } = await customGqlFetch<{ stakers: StakerData[] }>( getSubgraphUrl(networkData), GET_STAKERS_QUERY(filter), { @@ -572,7 +584,8 @@ export class StakingUtils { orderDirection: orderDirection, first: first, skip: skip, - } + }, + options ); if (!stakers) { return []; diff --git a/packages/sdk/typescript/human-protocol-sdk/src/statistics.ts b/packages/sdk/typescript/human-protocol-sdk/src/statistics.ts index 715c094621..36095e7258 100644 --- a/packages/sdk/typescript/human-protocol-sdk/src/statistics.ts +++ b/packages/sdk/typescript/human-protocol-sdk/src/statistics.ts @@ -1,6 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import gqlFetch from 'graphql-request'; - import { OrderDirection } from './enums'; import { EscrowStatisticsData, @@ -21,9 +19,15 @@ import { IPaymentStatistics, IStatisticsFilter, IWorkerStatistics, + SubgraphOptions, } from './interfaces'; import { NetworkData } from './types'; -import { getSubgraphUrl, getUnixTimestamp, throwError } from './utils'; +import { + getSubgraphUrl, + getUnixTimestamp, + customGqlFetch, + throwError, +} from './utils'; /** * ## Introduction @@ -103,6 +107,7 @@ export class StatisticsClient { * ``` * * @param {IStatisticsFilter} filter Statistics params with duration data + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} Escrow statistics data. * * **Code example** @@ -120,7 +125,8 @@ export class StatisticsClient { * ``` */ async getEscrowStatistics( - filter: IStatisticsFilter = {} + filter: IStatisticsFilter = {}, + options?: SubgraphOptions ): Promise { try { const first = @@ -128,19 +134,24 @@ export class StatisticsClient { const skip = filter.skip || 0; const orderDirection = filter.orderDirection || OrderDirection.ASC; - const { escrowStatistics } = await gqlFetch<{ + const { escrowStatistics } = await customGqlFetch<{ escrowStatistics: EscrowStatisticsData; - }>(this.subgraphUrl, GET_ESCROW_STATISTICS_QUERY); + }>(this.subgraphUrl, GET_ESCROW_STATISTICS_QUERY, options); - const { eventDayDatas } = await gqlFetch<{ + const { eventDayDatas } = await customGqlFetch<{ eventDayDatas: EventDayData[]; - }>(this.subgraphUrl, GET_EVENT_DAY_DATA_QUERY(filter), { - from: filter.from ? getUnixTimestamp(filter.from) : undefined, - to: filter.to ? getUnixTimestamp(filter.to) : undefined, - orderDirection: orderDirection, - first: first, - skip: skip, - }); + }>( + this.subgraphUrl, + GET_EVENT_DAY_DATA_QUERY(filter), + { + from: filter.from ? getUnixTimestamp(filter.from) : undefined, + to: filter.to ? getUnixTimestamp(filter.to) : undefined, + orderDirection: orderDirection, + first: first, + skip: skip, + }, + options + ); return { totalEscrows: escrowStatistics?.totalEscrowCount @@ -187,6 +198,7 @@ export class StatisticsClient { * ``` * * @param {IStatisticsFilter} filter Statistics params with duration data + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} Worker statistics data. * * **Code example** @@ -204,7 +216,8 @@ export class StatisticsClient { * ``` */ async getWorkerStatistics( - filter: IStatisticsFilter = {} + filter: IStatisticsFilter = {}, + options?: SubgraphOptions ): Promise { try { const first = @@ -212,15 +225,20 @@ export class StatisticsClient { const skip = filter.skip || 0; const orderDirection = filter.orderDirection || OrderDirection.ASC; - const { eventDayDatas } = await gqlFetch<{ + const { eventDayDatas } = await customGqlFetch<{ eventDayDatas: EventDayData[]; - }>(this.subgraphUrl, GET_EVENT_DAY_DATA_QUERY(filter), { - from: filter.from ? getUnixTimestamp(filter.from) : undefined, - to: filter.to ? getUnixTimestamp(filter.to) : undefined, - orderDirection: orderDirection, - first: first, - skip: skip, - }); + }>( + this.subgraphUrl, + GET_EVENT_DAY_DATA_QUERY(filter), + { + from: filter.from ? getUnixTimestamp(filter.from) : undefined, + to: filter.to ? getUnixTimestamp(filter.to) : undefined, + orderDirection: orderDirection, + first: first, + skip: skip, + }, + options + ); return { dailyWorkersData: eventDayDatas.map((eventDayData) => ({ @@ -262,6 +280,7 @@ export class StatisticsClient { * ``` * * @param {IStatisticsFilter} filter Statistics params with duration data + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} Payment statistics data. * * **Code example** @@ -300,7 +319,8 @@ export class StatisticsClient { * ``` */ async getPaymentStatistics( - filter: IStatisticsFilter = {} + filter: IStatisticsFilter = {}, + options?: SubgraphOptions ): Promise { try { const first = @@ -308,15 +328,20 @@ export class StatisticsClient { const skip = filter.skip || 0; const orderDirection = filter.orderDirection || OrderDirection.ASC; - const { eventDayDatas } = await gqlFetch<{ + const { eventDayDatas } = await customGqlFetch<{ eventDayDatas: EventDayData[]; - }>(this.subgraphUrl, GET_EVENT_DAY_DATA_QUERY(filter), { - from: filter.from ? getUnixTimestamp(filter.from) : undefined, - to: filter.to ? getUnixTimestamp(filter.to) : undefined, - orderDirection: orderDirection, - first: first, - skip: skip, - }); + }>( + this.subgraphUrl, + GET_EVENT_DAY_DATA_QUERY(filter), + { + from: filter.from ? getUnixTimestamp(filter.from) : undefined, + to: filter.to ? getUnixTimestamp(filter.to) : undefined, + orderDirection: orderDirection, + first: first, + skip: skip, + }, + options + ); return { dailyPaymentsData: eventDayDatas.map((eventDayData) => ({ @@ -346,6 +371,7 @@ export class StatisticsClient { * }; * ``` * + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} HMToken statistics data. * * **Code example** @@ -363,11 +389,11 @@ export class StatisticsClient { * }); * ``` */ - async getHMTStatistics(): Promise { + async getHMTStatistics(options?: SubgraphOptions): Promise { try { - const { hmtokenStatistics } = await gqlFetch<{ + const { hmtokenStatistics } = await customGqlFetch<{ hmtokenStatistics: HMTStatisticsData; - }>(this.subgraphUrl, GET_HMTOKEN_STATISTICS_QUERY); + }>(this.subgraphUrl, GET_HMTOKEN_STATISTICS_QUERY, options); return { totalTransferAmount: BigInt(hmtokenStatistics.totalValueTransfered), @@ -385,6 +411,7 @@ export class StatisticsClient { * **Input parameters** * * @param {IHMTHoldersParams} params HMT Holders params with filters and ordering + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} List of HMToken holders. * * **Code example** @@ -404,19 +431,23 @@ export class StatisticsClient { * }))); * ``` */ - async getHMTHolders(params: IHMTHoldersParams = {}): Promise { + async getHMTHolders( + params: IHMTHoldersParams = {}, + options?: SubgraphOptions + ): Promise { try { const { address, orderDirection } = params; const query = GET_HOLDERS_QUERY(address); - const { holders } = await gqlFetch<{ holders: HMTHolderData[] }>( + const { holders } = await customGqlFetch<{ holders: HMTHolderData[] }>( this.subgraphUrl, query, { address, orderBy: 'balance', orderDirection, - } + }, + options ); return holders.map((holder) => ({ @@ -454,6 +485,7 @@ export class StatisticsClient { * ``` * * @param {IStatisticsFilter} filter Statistics params with duration data + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} Daily HMToken statistics data. * * **Code example** @@ -475,22 +507,30 @@ export class StatisticsClient { * console.log('HMT statistics from 5/8 - 6/8:', hmtStatisticsRange); * ``` */ - async getHMTDailyData(filter: IStatisticsFilter = {}): Promise { + async getHMTDailyData( + filter: IStatisticsFilter = {}, + options?: SubgraphOptions + ): Promise { try { const first = filter.first !== undefined ? Math.min(filter.first, 1000) : 10; const skip = filter.skip || 0; const orderDirection = filter.orderDirection || OrderDirection.ASC; - const { eventDayDatas } = await gqlFetch<{ + const { eventDayDatas } = await customGqlFetch<{ eventDayDatas: EventDayData[]; - }>(this.subgraphUrl, GET_EVENT_DAY_DATA_QUERY(filter), { - from: filter.from ? getUnixTimestamp(filter.from) : undefined, - to: filter.to ? getUnixTimestamp(filter.to) : undefined, - orderDirection: orderDirection, - first: first, - skip: skip, - }); + }>( + this.subgraphUrl, + GET_EVENT_DAY_DATA_QUERY(filter), + { + from: filter.from ? getUnixTimestamp(filter.from) : undefined, + to: filter.to ? getUnixTimestamp(filter.to) : undefined, + orderDirection: orderDirection, + first: first, + skip: skip, + }, + options + ); return eventDayDatas.map((eventDayData) => ({ timestamp: +eventDayData.timestamp * 1000, diff --git a/packages/sdk/typescript/human-protocol-sdk/src/transaction.ts b/packages/sdk/typescript/human-protocol-sdk/src/transaction.ts index fc8cd3e987..0b2014b495 100644 --- a/packages/sdk/typescript/human-protocol-sdk/src/transaction.ts +++ b/packages/sdk/typescript/human-protocol-sdk/src/transaction.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ethers } from 'ethers'; -import gqlFetch from 'graphql-request'; import { NETWORKS } from './constants'; import { ChainId, OrderDirection } from './enums'; import { @@ -17,8 +16,9 @@ import { InternalTransaction, ITransaction, ITransactionsFilter, + SubgraphOptions, } from './interfaces'; -import { getSubgraphUrl, getUnixTimestamp } from './utils'; +import { getSubgraphUrl, getUnixTimestamp, customGqlFetch } from './utils'; export class TransactionUtils { /** @@ -54,6 +54,7 @@ export class TransactionUtils { * * @param {ChainId} chainId The chain ID. * @param {string} hash The transaction hash. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} - Returns the transaction details or null if not found. * * **Code example** @@ -66,7 +67,8 @@ export class TransactionUtils { */ public static async getTransaction( chainId: ChainId, - hash: string + hash: string, + options?: SubgraphOptions ): Promise { if (!ethers.isHexString(hash)) { throw ErrorInvalidHashProvided; @@ -77,11 +79,16 @@ export class TransactionUtils { throw ErrorUnsupportedChainID; } - const { transaction } = await gqlFetch<{ + const { transaction } = await customGqlFetch<{ transaction: TransactionData | null; - }>(getSubgraphUrl(networkData), GET_TRANSACTION_QUERY, { - hash: hash.toLowerCase(), - }); + }>( + getSubgraphUrl(networkData), + GET_TRANSACTION_QUERY, + { + hash: hash.toLowerCase(), + }, + options + ); if (!transaction) return null; return mapTransaction(transaction); @@ -141,6 +148,7 @@ export class TransactionUtils { * ``` * * @param {ITransactionsFilter} filter Filter for the transactions. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} Returns an array with all the transaction details. * * **Code example** @@ -160,7 +168,8 @@ export class TransactionUtils { * ``` */ public static async getTransactions( - filter: ITransactionsFilter + filter: ITransactionsFilter, + options?: SubgraphOptions ): Promise { if ( (!!filter.startDate || !!filter.endDate) && @@ -179,24 +188,29 @@ export class TransactionUtils { throw ErrorUnsupportedChainID; } - const { transactions } = await gqlFetch<{ + const { transactions } = await customGqlFetch<{ transactions: TransactionData[]; - }>(getSubgraphUrl(networkData), GET_TRANSACTIONS_QUERY(filter), { - fromAddress: filter?.fromAddress, - toAddress: filter?.toAddress, - startDate: filter?.startDate - ? getUnixTimestamp(filter?.startDate) - : undefined, - endDate: filter.endDate ? getUnixTimestamp(filter.endDate) : undefined, - startBlock: filter.startBlock ? filter.startBlock : undefined, - endBlock: filter.endBlock ? filter.endBlock : undefined, - method: filter.method ? filter.method : undefined, - escrow: filter.escrow ? filter.escrow : undefined, - token: filter.token ? filter.token : undefined, - orderDirection: orderDirection, - first: first, - skip: skip, - }); + }>( + getSubgraphUrl(networkData), + GET_TRANSACTIONS_QUERY(filter), + { + fromAddress: filter?.fromAddress, + toAddress: filter?.toAddress, + startDate: filter?.startDate + ? getUnixTimestamp(filter?.startDate) + : undefined, + endDate: filter.endDate ? getUnixTimestamp(filter.endDate) : undefined, + startBlock: filter.startBlock ? filter.startBlock : undefined, + endBlock: filter.endBlock ? filter.endBlock : undefined, + method: filter.method ? filter.method : undefined, + escrow: filter.escrow ? filter.escrow : undefined, + token: filter.token ? filter.token : undefined, + orderDirection: orderDirection, + first: first, + skip: skip, + }, + options + ); if (!transactions) { return []; diff --git a/packages/sdk/typescript/human-protocol-sdk/src/utils.ts b/packages/sdk/typescript/human-protocol-sdk/src/utils.ts index 975215da05..cfe35456b7 100644 --- a/packages/sdk/typescript/human-protocol-sdk/src/utils.ts +++ b/packages/sdk/typescript/human-protocol-sdk/src/utils.ts @@ -1,11 +1,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ethers } from 'ethers'; +import gqlFetch from 'graphql-request'; import { isURL } from 'validator'; import { SUBGRAPH_API_KEY_PLACEHOLDER } from './constants'; import { ChainId } from './enums'; import { ContractExecutionError, + ErrorRetryParametersMissing, EthereumError, InvalidArgumentError, NonceExpired, @@ -15,6 +17,7 @@ import { WarnSubgraphApiKeyNotProvided, } from './error'; import { NetworkData } from './types'; +import { SubgraphOptions } from './interfaces'; /** * **Handle and throw the error.* @@ -99,3 +102,64 @@ export const getSubgraphUrl = (networkData: NetworkData) => { export const getUnixTimestamp = (date: Date): number => { return Math.floor(date.getTime() / 1000); }; + +export const isIndexerError = (error: any): boolean => { + if (!error) return false; + + const errorMessage = + error.response?.errors?.[0]?.message || + error.message || + error.toString() || + ''; + return errorMessage.toLowerCase().includes('bad indexers'); +}; + +const sleep = (ms: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; + +/** + * Execute a GraphQL request with automatic retry logic for bad indexer errors. + * Only retries if options is provided. + */ +export const customGqlFetch = async ( + url: string, + query: any, + variables?: any, + options?: SubgraphOptions +): Promise => { + if (!options) { + return await gqlFetch(url, query, variables); + } + + if ( + (options.maxRetries && options.baseDelay === undefined) || + (options.baseDelay && options.maxRetries === undefined) + ) { + throw ErrorRetryParametersMissing; + } + + let lastError: any; + + for (let attempt = 0; attempt <= (options.maxRetries as number); attempt++) { + try { + const result = await gqlFetch(url, query, variables); + return result; + } catch (error) { + lastError = error; + + if (attempt === options.maxRetries) { + throw error; + } + + if (!isIndexerError(error)) { + throw error; + } + + const delay = (options.baseDelay as number) * attempt; + await sleep(delay); + } + } + + throw lastError; +}; diff --git a/packages/sdk/typescript/human-protocol-sdk/src/worker.ts b/packages/sdk/typescript/human-protocol-sdk/src/worker.ts index 60fa11ed5f..6d37ec10bc 100644 --- a/packages/sdk/typescript/human-protocol-sdk/src/worker.ts +++ b/packages/sdk/typescript/human-protocol-sdk/src/worker.ts @@ -1,12 +1,11 @@ import { ethers } from 'ethers'; -import gqlFetch from 'graphql-request'; import { NETWORKS } from './constants'; import { ChainId, OrderDirection } from './enums'; import { ErrorInvalidAddress, ErrorUnsupportedChainID } from './error'; import { WorkerData } from './graphql'; import { GET_WORKER_QUERY, GET_WORKERS_QUERY } from './graphql/queries/worker'; -import { IWorker, IWorkersFilter } from './interfaces'; -import { getSubgraphUrl } from './utils'; +import { IWorker, IWorkersFilter, SubgraphOptions } from './interfaces'; +import { getSubgraphUrl, customGqlFetch } from './utils'; export class WorkerUtils { /** @@ -14,6 +13,7 @@ export class WorkerUtils { * * @param {ChainId} chainId The chain ID. * @param {string} address The worker address. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} - Returns the worker details or null if not found. * * **Code example** @@ -26,7 +26,8 @@ export class WorkerUtils { */ public static async getWorker( chainId: ChainId, - address: string + address: string, + options?: SubgraphOptions ): Promise { const networkData = NETWORKS[chainId]; @@ -37,11 +38,16 @@ export class WorkerUtils { throw ErrorInvalidAddress; } - const { worker } = await gqlFetch<{ + const { worker } = await customGqlFetch<{ worker: WorkerData | null; - }>(getSubgraphUrl(networkData), GET_WORKER_QUERY, { - address: address.toLowerCase(), - }); + }>( + getSubgraphUrl(networkData), + GET_WORKER_QUERY, + { + address: address.toLowerCase(), + }, + options + ); if (!worker) return null; @@ -74,6 +80,7 @@ export class WorkerUtils { * ``` * * @param {IWorkersFilter} filter Filter for the workers. + * @param {SubgraphOptions} options Optional configuration for subgraph requests. * @returns {Promise} Returns an array with all the worker details. * * **Code example** @@ -89,7 +96,10 @@ export class WorkerUtils { * const workers = await WorkerUtils.getWorkers(filter); * ``` */ - public static async getWorkers(filter: IWorkersFilter): Promise { + public static async getWorkers( + filter: IWorkersFilter, + options?: SubgraphOptions + ): Promise { const first = filter.first !== undefined ? Math.min(filter.first, 1000) : 10; const skip = filter.skip || 0; @@ -104,15 +114,20 @@ export class WorkerUtils { throw ErrorInvalidAddress; } - const { workers } = await gqlFetch<{ + const { workers } = await customGqlFetch<{ workers: WorkerData[]; - }>(getSubgraphUrl(networkData), GET_WORKERS_QUERY(filter), { - address: filter?.address?.toLowerCase(), - first: first, - skip: skip, - orderBy: orderBy, - orderDirection: orderDirection, - }); + }>( + getSubgraphUrl(networkData), + GET_WORKERS_QUERY(filter), + { + address: filter?.address?.toLowerCase(), + first: first, + skip: skip, + orderBy: orderBy, + orderDirection: orderDirection, + }, + options + ); if (!workers) { return []; diff --git a/packages/sdk/typescript/human-protocol-sdk/test/kvstore.test.ts b/packages/sdk/typescript/human-protocol-sdk/test/kvstore.test.ts index 80571d29ab..c918e03333 100644 --- a/packages/sdk/typescript/human-protocol-sdk/test/kvstore.test.ts +++ b/packages/sdk/typescript/human-protocol-sdk/test/kvstore.test.ts @@ -560,7 +560,8 @@ describe('KVStoreUtils', () => { expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, '0x42d75a16b04a02d1abd7f2386b1c5b567bc7ef71', - 'url' + 'url', + undefined ); }); @@ -581,7 +582,8 @@ describe('KVStoreUtils', () => { expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, '0x42d75a16b04a02d1abd7f2386b1c5b567bc7ef71', - 'url' + 'url', + undefined ); expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, @@ -608,7 +610,8 @@ describe('KVStoreUtils', () => { expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, '0x42d75a16b04a02d1abd7f2386b1c5b567bc7ef71', - 'linkedin_url' + 'linkedin_url', + undefined ); expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, @@ -635,7 +638,8 @@ describe('KVStoreUtils', () => { expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, '0x42d75a16b04a02d1abd7f2386b1c5b567bc7ef71', - 'url' + 'url', + undefined ); expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, @@ -659,7 +663,8 @@ describe('KVStoreUtils', () => { expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, '0x42d75a16b04a02d1abd7f2386b1c5b567bc7ef71', - 'url' + 'url', + undefined ); }); }); @@ -682,7 +687,8 @@ describe('KVStoreUtils', () => { expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, '0x42d75a16b04a02d1abd7f2386b1c5b567bc7ef71', - 'public_key' + 'public_key', + undefined ); }); @@ -703,7 +709,8 @@ describe('KVStoreUtils', () => { expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, '0x42d75a16b04a02d1abd7f2386b1c5b567bc7ef71', - 'public_key' + 'public_key', + undefined ); expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, @@ -732,7 +739,8 @@ describe('KVStoreUtils', () => { expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, '0x42d75a16b04a02d1abd7f2386b1c5b567bc7ef71', - 'public_key' + 'public_key', + undefined ); expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, @@ -756,7 +764,8 @@ describe('KVStoreUtils', () => { expect(KVStoreUtils.get).toHaveBeenCalledWith( ChainId.LOCALHOST, '0x42d75a16b04a02d1abd7f2386b1c5b567bc7ef71', - 'public_key' + 'public_key', + undefined ); }); }); diff --git a/packages/sdk/typescript/human-protocol-sdk/test/statistics.test.ts b/packages/sdk/typescript/human-protocol-sdk/test/statistics.test.ts index 8d6a741df2..0f36f8449b 100644 --- a/packages/sdk/typescript/human-protocol-sdk/test/statistics.test.ts +++ b/packages/sdk/typescript/human-protocol-sdk/test/statistics.test.ts @@ -62,7 +62,8 @@ describe('StatisticsClient', () => { expect(gqlFetchSpy).toHaveBeenCalledWith( 'https://api.studio.thegraph.com/query/74256/polygon/version/latest', - GET_ESCROW_STATISTICS_QUERY + GET_ESCROW_STATISTICS_QUERY, + undefined ); expect(gqlFetchSpy).toHaveBeenCalledWith( 'https://api.studio.thegraph.com/query/74256/polygon/version/latest', diff --git a/packages/sdk/typescript/human-protocol-sdk/test/utils.test.ts b/packages/sdk/typescript/human-protocol-sdk/test/utils.test.ts index 745119098c..6c6a0428b8 100644 --- a/packages/sdk/typescript/human-protocol-sdk/test/utils.test.ts +++ b/packages/sdk/typescript/human-protocol-sdk/test/utils.test.ts @@ -1,5 +1,13 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { describe, expect, test, vi } from 'vitest'; + +vi.mock('graphql-request', () => { + return { + default: vi.fn(), + }; +}); + +import * as gqlFetch from 'graphql-request'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { ChainId } from '../src'; import { NETWORKS } from '../src/constants'; import { @@ -11,10 +19,13 @@ import { ReplacementUnderpriced, TransactionReplaced, WarnSubgraphApiKeyNotProvided, + ErrorRetryParametersMissing, } from '../src/error'; import { getSubgraphUrl, getUnixTimestamp, + customGqlFetch, + isIndexerError, isValidJson, isValidUrl, throwError, @@ -142,3 +153,189 @@ describe('throwError', () => { expect(() => throwError(errorObj)).toThrow(expectedError); }); }); + +describe('isIndexerError', () => { + test('returns false for null/undefined errors', () => { + expect(isIndexerError(null)).toBe(false); + expect(isIndexerError(undefined)).toBe(false); + expect(isIndexerError('')).toBe(false); + }); + + test('returns true for GraphQL errors with "bad indexers" message', () => { + const error = { + response: { + errors: [ + { + message: + 'bad indexers: {0xbdfb5ee5a2abf4fc7bb1bd1221067aef7f9de491: Timeout}', + }, + ], + }, + }; + expect(isIndexerError(error)).toBe(true); + }); + + test('returns false for regular GraphQL errors', () => { + const error = { + response: { + errors: [ + { + message: 'Field "unknownField" is not defined', + }, + ], + }, + }; + expect(isIndexerError(error)).toBe(false); + }); + + test('returns false for network/connection errors', () => { + const error = { + message: 'Network error: ECONNREFUSED', + }; + expect(isIndexerError(error)).toBe(false); + }); + + test('returns false for validation errors', () => { + const error = { + message: 'Invalid query syntax', + }; + expect(isIndexerError(error)).toBe(false); + }); +}); + +describe('customGqlFetch', () => { + const mockUrl = 'http://test-subgraph.com'; + const mockQuery = 'query { test }'; + const mockVariables = { id: '123' }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + test('calls gqlFetch directly when no config provided', async () => { + const expectedResult = { data: 'test' }; + const gqlFetchSpy = vi + .spyOn(gqlFetch, 'default') + .mockResolvedValue(expectedResult); + + const result = await customGqlFetch(mockUrl, mockQuery, mockVariables); + + expect(gqlFetchSpy).toHaveBeenCalledTimes(1); + expect(gqlFetchSpy).toHaveBeenCalledWith(mockUrl, mockQuery, mockVariables); + expect(result).toBe(expectedResult); + }); + + test('succeeds on first attempt with config', async () => { + const expectedResult = { data: 'test' }; + const gqlFetchSpy = vi + .spyOn(gqlFetch, 'default') + .mockResolvedValue(expectedResult); + + const result = await customGqlFetch(mockUrl, mockQuery, mockVariables, { + maxRetries: 3, + baseDelay: 100, + }); + + expect(gqlFetchSpy).toHaveBeenCalledTimes(1); + expect(result).toBe(expectedResult); + }); + + test('retries on bad indexers error', async () => { + const badIndexerError = { + response: { + errors: [{ message: 'bad indexers: {0x123: Timeout}' }], + }, + }; + const expectedResult = { data: 'success' }; + const gqlFetchSpy = vi + .spyOn(gqlFetch, 'default') + .mockRejectedValueOnce(badIndexerError) + .mockRejectedValueOnce(badIndexerError) + .mockResolvedValueOnce(expectedResult); + + const result = await customGqlFetch(mockUrl, mockQuery, mockVariables, { + maxRetries: 3, + baseDelay: 10, + }); + + expect(gqlFetchSpy).toHaveBeenCalledTimes(3); + expect(result).toBe(expectedResult); + }); + + test('throws immediately on non-indexer errors', async () => { + const regularError = new Error('Regular GraphQL error'); + const gqlFetchSpy = vi + .spyOn(gqlFetch, 'default') + .mockRejectedValue(regularError); + + await expect( + customGqlFetch(mockUrl, mockQuery, mockVariables, { + maxRetries: 3, + baseDelay: 10, + }) + ).rejects.toThrow('Regular GraphQL error'); + + expect(gqlFetchSpy).toHaveBeenCalledTimes(1); + }); + + test('throws after max retries exceeded', async () => { + const badIndexerError = { + response: { + errors: [{ message: 'bad indexers: {0x123: Timeout}' }], + }, + }; + const gqlFetchSpy = vi + .spyOn(gqlFetch, 'default') + .mockRejectedValue(badIndexerError); + + await expect( + customGqlFetch(mockUrl, mockQuery, mockVariables, { + maxRetries: 2, + baseDelay: 10, + }) + ).rejects.toEqual(badIndexerError); + + expect(gqlFetchSpy).toHaveBeenCalledTimes(3); + }); + + test('throws when retry options are incomplete', async () => { + const gqlFetchSpy = vi.spyOn(gqlFetch, 'default'); + + await expect( + customGqlFetch(mockUrl, mockQuery, mockVariables, { baseDelay: 10 }) + ).rejects.toBe(ErrorRetryParametersMissing); + + expect(gqlFetchSpy).not.toHaveBeenCalled(); + }); + + test('throws when only maxRetries is provided', async () => { + const gqlFetchSpy = vi.spyOn(gqlFetch, 'default'); + + await expect( + customGqlFetch(mockUrl, mockQuery, mockVariables, { maxRetries: 1 }) + ).rejects.toBe(ErrorRetryParametersMissing); + + expect(gqlFetchSpy).not.toHaveBeenCalled(); + }); + + test('uses custom maxRetries when provided', async () => { + const badIndexerError = { + response: { + errors: [{ message: 'bad indexers: {0x123: Timeout}' }], + }, + }; + const gqlFetchSpy = vi + .spyOn(gqlFetch, 'default') + .mockRejectedValue(badIndexerError); + + await expect( + customGqlFetch(mockUrl, mockQuery, mockVariables, { + maxRetries: 1, + baseDelay: 10, + }) + ).rejects.toEqual(badIndexerError); + + expect(gqlFetchSpy).toHaveBeenCalledTimes(2); + }); +});