Skip to content

Commit b180a88

Browse files
authored
Merge branch 'main' into update-file-handler
2 parents 6b10d44 + dfbbf4e commit b180a88

10 files changed

Lines changed: 549 additions & 9 deletions

File tree

src/hiero_sdk_python/client/client.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ def __init__(self, network: Network = None) -> None:
6666
self._grpc_deadline: float = DEFAULT_GRPC_DEADLINE
6767
self._request_timeout: float = DEFAULT_REQUEST_TIMEOUT
6868

69+
self._allow_receipt_node_failover: bool = False
70+
6971
self.logger: Logger = Logger(LogLevel.from_env(), "hiero_sdk_python")
7072

7173
@property
@@ -424,6 +426,33 @@ def update_network(self) -> Client:
424426
self.network._set_network_nodes()
425427
return self
426428

429+
@property
430+
def allow_receipt_node_failover(self) -> bool:
431+
"""
432+
Return whether receipt and record queries can fail over to other nodes.
433+
"""
434+
return self._allow_receipt_node_failover
435+
436+
def set_allow_receipt_node_failover(self, allow: bool) -> Client:
437+
"""
438+
Enable or disable receipt and record query node failover.
439+
440+
Args:
441+
allow (bool): Whether to allow receipt/record queries to fail over to
442+
other nodes when the submitting node is unavailable.
443+
444+
Returns:
445+
Client: This client instance for fluent chaining.
446+
447+
Raises:
448+
TypeError: If allow is not a bool.
449+
"""
450+
if not isinstance(allow, bool):
451+
raise TypeError("allow must be an instance of bool")
452+
453+
self._allow_receipt_node_failover = allow
454+
return self
455+
427456
def __enter__(self) -> Client:
428457
"""
429458
Allows the Client to be used in a 'with' statement for automatic resource management.

src/hiero_sdk_python/executable.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ def _calculate_backoff(self, attempt: int):
382382
def _handle_unhealthy_node(self, proto_request, attempt, logger, err) -> bool:
383383
"""Handle node switching and backoff for unhealthy node."""
384384
# Check if the request is a transaction receipt or record because they are single node requests
385-
if _is_transaction_receipt_or_record_request(proto_request):
385+
if _is_transaction_receipt_or_record_request(proto_request) and len(self._node_account_ids) <= 1:
386386
_delay_for_attempt(
387387
self._get_request_id(),
388388
self._min_backoff,

src/hiero_sdk_python/transaction/transaction.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ def _map_response(self, response, node_id, proto_request): # noqa: ARG002
109109
transaction_response.transaction_id = self.transaction_id
110110
transaction_response.node_id = node_id
111111
transaction_response.hash = tx_hash
112+
transaction_response._transaction_node_ids = self._node_account_ids.get_list()
112113

113114
return transaction_response
114115

@@ -364,6 +365,18 @@ def execute(
364365
if self.batch_key and not isinstance(self, (BatchTransaction)):
365366
raise ValueError("Cannot execute batchified transaction outside of BatchTransaction.")
366367

368+
if timeout is not None and (isinstance(timeout, bool) or not isinstance(timeout, (int, float))):
369+
raise TypeError("timeout must be a int or float")
370+
371+
if not isinstance(validate_status, bool):
372+
raise TypeError("validate_status must be a boolean")
373+
374+
if not isinstance(wait_for_receipt, bool):
375+
raise TypeError("wait_for_receipt must be a boolean")
376+
377+
if not isinstance(client, Client):
378+
raise TypeError("client must be an instance of Client")
379+
367380
if not self._transaction_body_bytes:
368381
self.freeze_with(client)
369382

@@ -374,7 +387,7 @@ def execute(
374387
self.sign(client.operator_private_key)
375388

376389
# Call the _execute function from executable.py to handle the actual execution
377-
response = self._execute(client, timeout)
390+
response: TransactionResponse = self._execute(client, timeout)
378391

379392
response.validate_status = True
380393
response.transaction = self

src/hiero_sdk_python/transaction/transaction_response.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,35 @@ def __init__(self) -> None:
3333
self.hash: bytes = b""
3434
self.validate_status: bool = False
3535
self.transaction: Transaction | None = None
36+
self._transaction_node_ids: list[AccountId] | None = None
3637

37-
def get_receipt_query(self, validate_status: bool = False):
38+
def get_receipt_query(self, validate_status: bool = False, client: Client | None = None):
3839
"""
3940
Create a receipt query for this transaction.
4041
4142
Args:
4243
validate_status (bool, optional): The query should automatically validate the transaction status. (default False)
44+
client (Client, optional): The client to enable failover behavior.
4345
4446
Returns:
4547
TransactionGetReceiptQuery: A configured receipt query.
48+
49+
Raises:
50+
TypeError: If `validate_status` is not a bool or `client` is not a Client.
4651
"""
4752
from hiero_sdk_python.query.transaction_get_receipt_query import TransactionGetReceiptQuery
4853

54+
if not isinstance(validate_status, bool):
55+
raise TypeError("validate_status must be a boolean")
56+
57+
if client is not None and not isinstance(client, Client):
58+
raise TypeError("client must be an instance of Client")
59+
60+
node_account_ids = self._resolve_node_account_ids(client)
4961
return (
5062
TransactionGetReceiptQuery()
5163
.set_transaction_id(self.transaction_id)
52-
.set_node_account_ids([self.node_id])
64+
.set_node_account_ids(node_account_ids)
5365
.set_validate_status(validate_status)
5466
)
5567

@@ -68,18 +80,28 @@ def get_receipt(
6880
TransactionReceipt: The receipt from the network, containing the status
6981
and any entities created by the transaction
7082
"""
71-
return self.get_receipt_query(validate_status=validate_status).execute(client, timeout)
83+
return self.get_receipt_query(validate_status=validate_status, client=client).execute(client, timeout)
7284

73-
def get_record_query(self):
85+
def get_record_query(self, client: Client | None = None):
7486
"""
7587
Create a record query for this transaction.
7688
89+
Args:
90+
client (Client, optional): The client to enable failover behavior.
91+
7792
Returns:
7893
TransactionRecordQuery: A configured record query.
94+
95+
Raises:
96+
TypeError: If `client` is not a Client.
7997
"""
8098
from hiero_sdk_python.query.transaction_record_query import TransactionRecordQuery
8199

82-
return TransactionRecordQuery().set_transaction_id(self.transaction_id).set_node_account_ids([self.node_id])
100+
if client is not None and not isinstance(client, Client):
101+
raise TypeError("client must be an instance of Client")
102+
103+
node_account_ids = self._resolve_node_account_ids(client)
104+
return TransactionRecordQuery().set_transaction_id(self.transaction_id).set_node_account_ids(node_account_ids)
83105

84106
def get_record(self, client: Client, timeout: int | float | None = None) -> TransactionRecord:
85107
"""
@@ -92,4 +114,15 @@ def get_record(self, client: Client, timeout: int | float | None = None) -> Tran
92114
Returns:
93115
TransactionRecord: The full transaction record.
94116
"""
95-
return self.get_record_query().execute(client, timeout)
117+
return self.get_record_query(client).execute(client, timeout)
118+
119+
def _resolve_node_account_ids(self, client: Client) -> list[AccountId]:
120+
"""Resolve node account IDs for receipt or record query failover."""
121+
node_account_ids = [self.node_id]
122+
123+
if client is None or not client.allow_receipt_node_failover:
124+
return node_account_ids
125+
126+
available_node_ids = self._transaction_node_ids if self._transaction_node_ids else client.get_node_account_ids()
127+
node_account_ids.extend(node_id for node_id in available_node_ids if node_id != self.node_id)
128+
return node_account_ids

tests/integration/transaction_e2e_test.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,41 @@ def test_get_receipt_raises_exception_on_failure_with_validation(env):
258258
response.get_receipt(env.client, validate_status=True)
259259

260260
assert e.value.status == ResponseCode.INVALID_ACCOUNT_ID
261+
262+
263+
@pytest.mark.integration
264+
def test_get_receipt_with_allow_failover(env):
265+
"""Test that a transaction receipt can be retrieved with node failover enabled."""
266+
env.client.set_allow_receipt_node_failover(True)
267+
268+
tx = AccountCreateTransaction().set_key_without_alias(PrivateKey.generate_ecdsa())
269+
response = tx.execute(env.client, wait_for_receipt=False)
270+
271+
assert response is not None
272+
273+
receipt = response.get_receipt(env.client)
274+
assert receipt is not None
275+
assert receipt.account_id is not None
276+
277+
AccountDeleteTransaction().set_transfer_account_id(env.operator_id).set_account_id(receipt.account_id).execute(
278+
env.client
279+
)
280+
281+
282+
@pytest.mark.integration
283+
def test_get_record_with_allow_failover(env):
284+
"""Test that a transaction record can be retrieved with node failover enabled."""
285+
env.client.set_allow_receipt_node_failover(True)
286+
287+
tx = AccountCreateTransaction().set_key_without_alias(PrivateKey.generate_ecdsa())
288+
response = tx.execute(env.client, wait_for_receipt=False)
289+
290+
assert response is not None
291+
292+
record = response.get_record(env.client)
293+
assert record is not None
294+
assert record.receipt.account_id is not None
295+
296+
AccountDeleteTransaction().set_transfer_account_id(env.operator_id).set_account_id(
297+
record.receipt.account_id
298+
).execute(env.client)

tests/unit/client_test.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,3 +604,22 @@ def test_for_network_invalid_shard_realm_raises_error(invalid_map, error_msg):
604604
"""Test that for_network catches mismatched shards or realms."""
605605
with pytest.raises(ValueError, match=error_msg):
606606
Client.for_network(invalid_map)
607+
608+
609+
def test_set_receipt_failover_set_values():
610+
"""Test that receipt failover is set to the provided value."""
611+
client = Client.for_testnet()
612+
# default
613+
assert client.allow_receipt_node_failover is False
614+
615+
return_value = client.set_allow_receipt_node_failover(True)
616+
assert client.allow_receipt_node_failover is True
617+
assert return_value is client
618+
619+
620+
@pytest.mark.parametrize("allow", ["true", 1, 0.1, [], {}, None])
621+
def test_set_receipt_failover_rejects_non_bool(allow):
622+
"""Test that non-boolean values are rejected."""
623+
client = Client.for_testnet()
624+
with pytest.raises(TypeError, match="allow must be an instance of bool"):
625+
client.set_allow_receipt_node_failover(allow)

0 commit comments

Comments
 (0)