Skip to content

Commit 7100dcb

Browse files
committed
chore: added test for executable.py
Signed-off-by: Manish Dait <[email protected]>
1 parent d5fbcfd commit 7100dcb

File tree

2 files changed

+220
-3
lines changed

2 files changed

+220
-3
lines changed

src/hiero_sdk_python/executable.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ def set_max_attempts(self, max_attempts: int):
134134
self._max_attempts = max_attempts
135135
return self
136136

137-
def set_grpc_deadline(self, grpc_deadline: Union[int, float]) -> "_Executable":
137+
def set_grpc_deadline(self, grpc_deadline: Union[int, float]):
138138
"""
139139
Set the gRPC call deadline (per attempt).
140140
@@ -217,7 +217,7 @@ def set_min_backoff(self, min_backoff: Union[int, float]):
217217
self._min_backoff = min_backoff
218218
return self
219219

220-
def set_max_backoff(self, max_backoff: Union[int, float]) -> "Client":
220+
def set_max_backoff(self, max_backoff: Union[int, float]):
221221
"""
222222
Set the maximum backoff delay between retries.
223223

tests/unit/executable_test.py

Lines changed: 218 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import pytest
22
import grpc
33
from unittest.mock import patch
4+
from itertools import chain, repeat
45

56
from hiero_sdk_python.account.account_create_transaction import AccountCreateTransaction
67
from hiero_sdk_python.account.account_id import AccountId
78
from hiero_sdk_python.crypto.private_key import PrivateKey
89
from hiero_sdk_python.exceptions import MaxAttemptsError, PrecheckError
10+
from hiero_sdk_python.executable import _is_transaction_receipt_or_record_request
911
from hiero_sdk_python.hapi.services import (
1012
basic_types_pb2,
1113
crypto_get_account_balance_pb2,
14+
query_pb2,
1215
response_header_pb2,
1316
response_pb2,
1417
transaction_get_receipt_pb2,
@@ -19,7 +22,10 @@
1922
)
2023
from hiero_sdk_python.consensus.topic_create_transaction import TopicCreateTransaction
2124
from hiero_sdk_python.query.account_balance_query import CryptoGetAccountBalanceQuery
25+
from hiero_sdk_python.query.transaction_get_receipt_query import TransactionGetReceiptQuery
26+
from hiero_sdk_python.query.transaction_record_query import TransactionRecordQuery
2227
from hiero_sdk_python.response_code import ResponseCode
28+
from hiero_sdk_python.transaction.transaction_id import TransactionId
2329
from tests.unit.mock_server import RealRpcError, mock_hedera_servers
2430

2531
pytestmark = pytest.mark.unit
@@ -723,6 +729,32 @@ def test_set_request_timeout_with_invalid_value(invalid_request_timeout):
723729
query = CryptoGetAccountBalanceQuery()
724730
query.set_request_timeout(invalid_request_timeout)
725731

732+
def test_warning_when_grpc_deadline_exceeds_request_timeout():
733+
"""Warn when grpc_deadline is greater than request_timeout."""
734+
tx = AccountCreateTransaction()
735+
736+
tx.set_request_timeout(5)
737+
738+
with pytest.warns(FutureWarning):
739+
tx.set_grpc_deadline(10)
740+
741+
def test_warning_when_request_timeout_less_than_grpc_deadline():
742+
"""Warn when request_timeout is less than grpc_deadline."""
743+
tx = AccountCreateTransaction()
744+
tx.set_grpc_deadline(10)
745+
746+
with pytest.warns(FutureWarning):
747+
tx.set_request_timeout(5)
748+
749+
def test_is_transaction_receipt_or_record_request():
750+
"""Detect receipt and record query requests correctly."""
751+
receipt_query = query_pb2.Query(
752+
transactionGetReceipt=transaction_get_receipt_pb2.TransactionGetReceiptQuery()
753+
)
754+
755+
assert _is_transaction_receipt_or_record_request(receipt_query) is True
756+
assert _is_transaction_receipt_or_record_request(object()) is False
757+
726758
# Set min_backoff
727759
def test_set_min_backoff_with_valid_param():
728760
"""Test that set_min_backoff updates default value of _min_backoff."""
@@ -860,6 +892,17 @@ def test_set_max_backoff_less_than_min_backoff():
860892

861893
query.set_max_backoff(2)
862894

895+
def test_backoff_is_capped_by_max_backoff():
896+
"""Backoff delay must not exceed max_backoff."""
897+
tx = AccountCreateTransaction()
898+
tx.set_min_backoff(2)
899+
tx.set_max_backoff(5)
900+
901+
# attempt=0 min * 2 = 4
902+
assert tx._calculate_backoff(0) == 4
903+
# attempt=1 min * 4 = 8 : capped to 5
904+
assert tx._calculate_backoff(1) == 5
905+
863906
def test_execution_config_inherits_from_client(mock_client):
864907
"""Test that resolve_execution_config inherits config from client if not set."""
865908
mock_client.max_attempts = 7
@@ -911,7 +954,7 @@ def test_set_node_account_ids_overrides_client_nodes(mock_client):
911954

912955
assert tx.node_account_ids == [node]
913956

914-
957+
# reuest timeout
915958
def test_request_timeout_exceeded_stops_execution():
916959
"""Test that execution stops when request_timeout is exceeded."""
917960
busy_response = TransactionResponseProto(
@@ -950,4 +993,178 @@ def fake_time():
950993
with pytest.raises(MaxAttemptsError):
951994
tx.execute(client)
952995

996+
997+
@pytest.mark.parametrize(
998+
'error',
999+
[
1000+
RealRpcError(grpc.StatusCode.DEADLINE_EXCEEDED, "timeout"),
1001+
RealRpcError(grpc.StatusCode.UNAVAILABLE, "unavailable"),
1002+
RealRpcError(grpc.StatusCode.RESOURCE_EXHAUSTED, "busy")
1003+
]
1004+
)
1005+
def test_should_exponential_error_mark_node_unhealty_and_advance(error):
1006+
"""Exponential gRPC retry errors advance the node without sleep-based backoff."""
1007+
ok_response = TransactionResponseProto(
1008+
nodeTransactionPrecheckCode=ResponseCode.OK
1009+
)
1010+
1011+
receipt_response = response_pb2.Response(
1012+
transactionGetReceipt=transaction_get_receipt_pb2.TransactionGetReceiptResponse(
1013+
header=response_header_pb2.ResponseHeader(
1014+
nodeTransactionPrecheckCode=ResponseCode.OK
1015+
),
1016+
receipt=transaction_receipt_pb2.TransactionReceipt(
1017+
status=ResponseCode.SUCCESS
1018+
)
1019+
)
1020+
)
1021+
1022+
response_sequences = [
1023+
[error],
1024+
[ok_response, receipt_response],
1025+
]
1026+
1027+
with mock_hedera_servers(response_sequences) as client, patch("hiero_sdk_python.executable.time.sleep") as mock_sleep:
1028+
tx = (
1029+
AccountCreateTransaction()
1030+
.set_key_without_alias(PrivateKey.generate().public_key())
1031+
.set_initial_balance(1)
1032+
)
1033+
1034+
receipt = tx.execute(client)
1035+
1036+
assert receipt.status == ResponseCode.SUCCESS
1037+
# No delay_for_attempt backoff call, Node is mark unhealthy and advance
1038+
assert mock_sleep.call_count == 0
1039+
# Node must have changed
1040+
assert tx._node_account_ids_index == 1
1041+
9531042

1043+
def test_rst_stream_error_marks_node_unhealthy_and_advances_without_backoff():
1044+
"""INTERNAL RST_STREAM errors trigger exponential retry by advancing the node without sleep-based backoff."""
1045+
error = RealRpcError(
1046+
grpc.StatusCode.INTERNAL,
1047+
"received rst stream"
1048+
)
1049+
1050+
ok_response = TransactionResponseProto(
1051+
nodeTransactionPrecheckCode=ResponseCode.OK
1052+
)
1053+
1054+
receipt_response = response_pb2.Response(
1055+
transactionGetReceipt=transaction_get_receipt_pb2.TransactionGetReceiptResponse(
1056+
header=response_header_pb2.ResponseHeader(
1057+
nodeTransactionPrecheckCode=ResponseCode.OK
1058+
),
1059+
receipt=transaction_receipt_pb2.TransactionReceipt(
1060+
status=ResponseCode.SUCCESS
1061+
)
1062+
)
1063+
)
1064+
1065+
response_sequences = [
1066+
[error],
1067+
[ok_response, receipt_response],
1068+
]
1069+
1070+
with (
1071+
mock_hedera_servers(response_sequences) as client,
1072+
patch("hiero_sdk_python.executable.time.sleep") as mock_sleep,
1073+
):
1074+
tx = (
1075+
AccountCreateTransaction()
1076+
.set_key_without_alias(PrivateKey.generate().public_key())
1077+
.set_initial_balance(1)
1078+
)
1079+
1080+
receipt = tx.execute(client)
1081+
1082+
# Retry succeeds
1083+
assert receipt.status == ResponseCode.SUCCESS
1084+
# RST_STREAM exponential retry does not use delay-based backoff
1085+
assert mock_sleep.call_count == 0
1086+
# Node must advance after marking the first node unhealthy
1087+
assert tx._node_account_ids_index == 1
1088+
1089+
1090+
@pytest.mark.parametrize(
1091+
'error',
1092+
[
1093+
RealRpcError(grpc.StatusCode.ALREADY_EXISTS, "already exists"),
1094+
RealRpcError(grpc.StatusCode.ABORTED, "aborted"),
1095+
RealRpcError(grpc.StatusCode.UNAUTHENTICATED, "unauthenticated")
1096+
]
1097+
)
1098+
def test_non_exponential_grpc_error_raises_exception(error):
1099+
"""Errors that are not retried exponentially should raise error immediately"""
1100+
response_sequences = [
1101+
[error]
1102+
]
1103+
1104+
with mock_hedera_servers(response_sequences) as client, pytest.raises(grpc.RpcError):
1105+
tx = (
1106+
AccountCreateTransaction()
1107+
.set_key_without_alias(PrivateKey.generate().public_key())
1108+
.set_initial_balance(1)
1109+
)
1110+
1111+
tx.execute(client)
1112+
1113+
def test_execution_skips_unhealthy_nodes_and_advances():
1114+
"""Execution should skip unhealthy nodes and advance to the next healthy one."""
1115+
busy_response = TransactionResponseProto(nodeTransactionPrecheckCode=ResponseCode.BUSY)
1116+
ok_response = TransactionResponseProto(nodeTransactionPrecheckCode=ResponseCode.OK)
1117+
1118+
receipt_response = response_pb2.Response(
1119+
transactionGetReceipt=transaction_get_receipt_pb2.TransactionGetReceiptResponse(
1120+
header=response_header_pb2.ResponseHeader(nodeTransactionPrecheckCode=ResponseCode.OK),
1121+
receipt=transaction_receipt_pb2.TransactionReceipt(status=ResponseCode.SUCCESS),
1122+
)
1123+
)
1124+
1125+
response_sequences = [
1126+
[busy_response], # first node (unhealthy)
1127+
[ok_response, receipt_response], # second node (healthy)
1128+
]
1129+
1130+
with mock_hedera_servers(response_sequences) as client, patch("hiero_sdk_python.node._Node.is_healthy", side_effect=chain([False, True], repeat(True))):
1131+
tx = AccountCreateTransaction().set_key_without_alias(PrivateKey.generate().public_key()).set_initial_balance(1)
1132+
1133+
receipt = tx.execute(client)
1134+
1135+
assert receipt.status == ResponseCode.SUCCESS
1136+
# Ensure the node index advanced past the unhealthy node
1137+
assert tx._node_account_ids_index == 1
1138+
1139+
1140+
def test_execution_raises_if_all_nodes_unhealthy(mock_client):
1141+
"""Execution should raise RuntimeError if all nodes are unhealthy."""
1142+
tx = AccountCreateTransaction().set_key_without_alias(PrivateKey.generate().public_key()).set_initial_balance(1)
1143+
1144+
# Patch node health to always return False
1145+
with patch("hiero_sdk_python.node._Node.is_healthy", side_effect=repeat(False)):
1146+
with pytest.raises(RuntimeError, match="All nodes are unhealthy"):
1147+
tx.execute(mock_client)
1148+
1149+
1150+
@pytest.mark.parametrize(
1151+
'tx',
1152+
[
1153+
TransactionRecordQuery().set_transaction_id(TransactionId.from_string("[email protected]")),
1154+
TransactionGetReceiptQuery().set_transaction_id(TransactionId.from_string("[email protected]"))
1155+
]
1156+
)
1157+
def test_unhealthy_node_receipt_request_triggers_delay_and_no_node_change(tx, mock_client):
1158+
"""Unhealthy node with transaction receipt/record request calls _delay_for_attempt but does not advance node."""
1159+
initial_index = tx._node_account_ids_index
1160+
1161+
with patch("hiero_sdk_python.node._Node.is_healthy", return_value=False), \
1162+
patch("hiero_sdk_python.executable._delay_for_attempt") as mock_delay:
1163+
1164+
with pytest.raises(Exception):
1165+
tx.execute(mock_client)
1166+
1167+
# _delay_for_attempt called
1168+
assert mock_delay.call_count > 0
1169+
# Node index did NOT change
1170+
assert tx._node_account_ids_index == initial_index

0 commit comments

Comments
 (0)