|
6 | 6 | from hiero_sdk_python.account.account_id import AccountId |
7 | 7 | from hiero_sdk_python.crypto.private_key import PrivateKey |
8 | 8 | from hiero_sdk_python.exceptions import MaxAttemptsError, PrecheckError |
| 9 | +from hiero_sdk_python.executable import _is_transaction_receipt_or_record_request |
9 | 10 | from hiero_sdk_python.hapi.services import ( |
10 | 11 | basic_types_pb2, |
11 | 12 | crypto_get_account_balance_pb2, |
| 13 | + query_pb2, |
12 | 14 | response_header_pb2, |
13 | 15 | response_pb2, |
14 | 16 | transaction_get_receipt_pb2, |
@@ -723,6 +725,32 @@ def test_set_request_timeout_with_invalid_value(invalid_request_timeout): |
723 | 725 | query = CryptoGetAccountBalanceQuery() |
724 | 726 | query.set_request_timeout(invalid_request_timeout) |
725 | 727 |
|
| 728 | +def test_warning_when_grpc_deadline_exceeds_request_timeout(): |
| 729 | + """Warn when grpc_deadline is greater than request_timeout.""" |
| 730 | + tx = AccountCreateTransaction() |
| 731 | + |
| 732 | + tx.set_request_timeout(5) |
| 733 | + |
| 734 | + with pytest.warns(FutureWarning): |
| 735 | + tx.set_grpc_deadline(10) |
| 736 | + |
| 737 | +def test_warning_when_request_timeout_less_than_grpc_deadline(): |
| 738 | + """Warn when request_timeout is less than grpc_deadline.""" |
| 739 | + tx = AccountCreateTransaction() |
| 740 | + tx.set_grpc_deadline(10) |
| 741 | + |
| 742 | + with pytest.warns(FutureWarning): |
| 743 | + tx.set_request_timeout(5) |
| 744 | + |
| 745 | +def test_is_transaction_receipt_or_record_request(): |
| 746 | + """Detect receipt and record query requests correctly.""" |
| 747 | + receipt_query = query_pb2.Query( |
| 748 | + transactionGetReceipt=transaction_get_receipt_pb2.TransactionGetReceiptQuery() |
| 749 | + ) |
| 750 | + |
| 751 | + assert _is_transaction_receipt_or_record_request(receipt_query) is True |
| 752 | + assert _is_transaction_receipt_or_record_request(object()) is False |
| 753 | + |
726 | 754 | # Set min_backoff |
727 | 755 | def test_set_min_backoff_with_valid_param(): |
728 | 756 | """Test that set_min_backoff updates default value of _min_backoff.""" |
@@ -860,6 +888,17 @@ def test_set_max_backoff_less_than_min_backoff(): |
860 | 888 |
|
861 | 889 | query.set_max_backoff(2) |
862 | 890 |
|
| 891 | +def test_backoff_is_capped_by_max_backoff(): |
| 892 | + """Backoff delay must not exceed max_backoff.""" |
| 893 | + tx = AccountCreateTransaction() |
| 894 | + tx.set_min_backoff(2) |
| 895 | + tx.set_max_backoff(5) |
| 896 | + |
| 897 | + # attempt=0 min * 2 = 4 |
| 898 | + assert tx._calculate_backoff(0) == 4 |
| 899 | + # attempt=1 min * 4 = 8 : capped to 5 |
| 900 | + assert tx._calculate_backoff(1) == 5 |
| 901 | + |
863 | 902 | def test_execution_config_inherits_from_client(mock_client): |
864 | 903 | """Test that resolve_execution_config inherits config from client if not set.""" |
865 | 904 | mock_client.max_attempts = 7 |
@@ -911,7 +950,7 @@ def test_set_node_account_ids_overrides_client_nodes(mock_client): |
911 | 950 |
|
912 | 951 | assert tx.node_account_ids == [node] |
913 | 952 |
|
914 | | - |
| 953 | +# reuest timeout |
915 | 954 | def test_request_timeout_exceeded_stops_execution(): |
916 | 955 | """Test that execution stops when request_timeout is exceeded.""" |
917 | 956 | busy_response = TransactionResponseProto( |
@@ -950,4 +989,119 @@ def fake_time(): |
950 | 989 | with pytest.raises(MaxAttemptsError): |
951 | 990 | tx.execute(client) |
952 | 991 |
|
| 992 | + |
| 993 | +@pytest.mark.parametrize( |
| 994 | + 'error', |
| 995 | + [ |
| 996 | + RealRpcError(grpc.StatusCode.DEADLINE_EXCEEDED, "timeout"), |
| 997 | + RealRpcError(grpc.StatusCode.UNAVAILABLE, "unavailable"), |
| 998 | + RealRpcError(grpc.StatusCode.RESOURCE_EXHAUSTED, "busy") |
| 999 | + ] |
| 1000 | +) |
| 1001 | +def test_should_exponential_error_mark_node_unhealty_and_advance(error): |
| 1002 | + """Exponential gRPC retry errors advance the node without sleep-based backoff.""" |
| 1003 | + ok_response = TransactionResponseProto( |
| 1004 | + nodeTransactionPrecheckCode=ResponseCode.OK |
| 1005 | + ) |
| 1006 | + |
| 1007 | + receipt_response = response_pb2.Response( |
| 1008 | + transactionGetReceipt=transaction_get_receipt_pb2.TransactionGetReceiptResponse( |
| 1009 | + header=response_header_pb2.ResponseHeader( |
| 1010 | + nodeTransactionPrecheckCode=ResponseCode.OK |
| 1011 | + ), |
| 1012 | + receipt=transaction_receipt_pb2.TransactionReceipt( |
| 1013 | + status=ResponseCode.SUCCESS |
| 1014 | + ) |
| 1015 | + ) |
| 1016 | + ) |
| 1017 | + |
| 1018 | + response_sequences = [ |
| 1019 | + [error], |
| 1020 | + [ok_response, receipt_response], |
| 1021 | + ] |
| 1022 | + |
| 1023 | + with mock_hedera_servers(response_sequences) as client, patch("hiero_sdk_python.executable.time.sleep") as mock_sleep: |
| 1024 | + tx = ( |
| 1025 | + AccountCreateTransaction() |
| 1026 | + .set_key_without_alias(PrivateKey.generate().public_key()) |
| 1027 | + .set_initial_balance(1) |
| 1028 | + ) |
| 1029 | + |
| 1030 | + receipt = tx.execute(client) |
| 1031 | + |
| 1032 | + assert receipt.status == ResponseCode.SUCCESS |
| 1033 | + # No delay_for_attempt backoff call, Node is mark unhealthy and advance |
| 1034 | + assert mock_sleep.call_count == 0 |
| 1035 | + # Node must have changed |
| 1036 | + assert tx._node_account_ids_index == 1 |
| 1037 | + |
953 | 1038 |
|
| 1039 | +def test_rst_stream_error_marks_node_unhealthy_and_advances_without_backoff(): |
| 1040 | + """INTERNAL RST_STREAM errors trigger exponential retry by advancing the node without sleep-based backoff.""" |
| 1041 | + error = RealRpcError( |
| 1042 | + grpc.StatusCode.INTERNAL, |
| 1043 | + "received rst stream" |
| 1044 | + ) |
| 1045 | + |
| 1046 | + ok_response = TransactionResponseProto( |
| 1047 | + nodeTransactionPrecheckCode=ResponseCode.OK |
| 1048 | + ) |
| 1049 | + |
| 1050 | + receipt_response = response_pb2.Response( |
| 1051 | + transactionGetReceipt=transaction_get_receipt_pb2.TransactionGetReceiptResponse( |
| 1052 | + header=response_header_pb2.ResponseHeader( |
| 1053 | + nodeTransactionPrecheckCode=ResponseCode.OK |
| 1054 | + ), |
| 1055 | + receipt=transaction_receipt_pb2.TransactionReceipt( |
| 1056 | + status=ResponseCode.SUCCESS |
| 1057 | + ) |
| 1058 | + ) |
| 1059 | + ) |
| 1060 | + |
| 1061 | + response_sequences = [ |
| 1062 | + [error], |
| 1063 | + [ok_response, receipt_response], |
| 1064 | + ] |
| 1065 | + |
| 1066 | + with ( |
| 1067 | + mock_hedera_servers(response_sequences) as client, |
| 1068 | + patch("hiero_sdk_python.executable.time.sleep") as mock_sleep, |
| 1069 | + ): |
| 1070 | + tx = ( |
| 1071 | + AccountCreateTransaction() |
| 1072 | + .set_key_without_alias(PrivateKey.generate().public_key()) |
| 1073 | + .set_initial_balance(1) |
| 1074 | + ) |
| 1075 | + |
| 1076 | + receipt = tx.execute(client) |
| 1077 | + |
| 1078 | + # Retry succeeds |
| 1079 | + assert receipt.status == ResponseCode.SUCCESS |
| 1080 | + # RST_STREAM exponential retry does not use delay-based backoff |
| 1081 | + assert mock_sleep.call_count == 0 |
| 1082 | + # Node must advance after marking the first node unhealthy |
| 1083 | + assert tx._node_account_ids_index == 1 |
| 1084 | + |
| 1085 | + |
| 1086 | +@pytest.mark.parametrize( |
| 1087 | + 'error', |
| 1088 | + [ |
| 1089 | + RealRpcError(grpc.StatusCode.ALREADY_EXISTS, "already exists"), |
| 1090 | + RealRpcError(grpc.StatusCode.ABORTED, "aborted"), |
| 1091 | + RealRpcError(grpc.StatusCode.UNAUTHENTICATED, "unauthenticated") |
| 1092 | + ] |
| 1093 | +) |
| 1094 | +def test_non_exponential_grpc_error_raises_exception(error): |
| 1095 | + """Errors that are not retried exponentially should raise error immediately""" |
| 1096 | + response_sequences = [ |
| 1097 | + [error] |
| 1098 | + ] |
| 1099 | + |
| 1100 | + with mock_hedera_servers(response_sequences) as client, pytest.raises(grpc.RpcError): |
| 1101 | + tx = ( |
| 1102 | + AccountCreateTransaction() |
| 1103 | + .set_key_without_alias(PrivateKey.generate().public_key()) |
| 1104 | + .set_initial_balance(1) |
| 1105 | + ) |
| 1106 | + |
| 1107 | + tx.execute(client) |
0 commit comments