Skip to content

Commit 2882580

Browse files
nadineloepfeaceppaluniDosik13
authored
fix: node account ids (#1020)
Signed-off-by: Angelina <aceppaluni@gmail.com> Signed-off-by: dosi <dosi.kolev@limechain.tech> Signed-off-by: nadineloepfe <nadine.loepfe@swirldslabs.com> Co-authored-by: Angelina <aceppaluni@gmail.com> Co-authored-by: dosi <dosi.kolev@limechain.tech> Co-authored-by: aceppaluni <113948612+aceppaluni@users.noreply.github.com>
1 parent ee17b82 commit 2882580

File tree

9 files changed

+173
-25
lines changed

9 files changed

+173
-25
lines changed

.github/workflows/test.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,14 @@ jobs:
105105
- name: Fail workflow if any tests failed
106106
shell: bash
107107
run: |
108-
if [ "${{ steps.integration.outputs.integration_failed }}" != "0" ] || [ "${{ steps.unit.outputs.unit_failed }}" != "0" ]; then
108+
integration_failed="${{ steps.integration.outputs.integration_failed }}"
109+
unit_failed="${{ steps.unit.outputs.unit_failed }}"
110+
111+
# Default to 0 if empty
112+
integration_failed="${integration_failed:-0}"
113+
unit_failed="${unit_failed:-0}"
114+
115+
if [ "$integration_failed" != "0" ] || [ "$unit_failed" != "0" ]; then
109116
echo "❌ Some tests failed. Failing workflow."
110117
exit 1
111118
else

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
1919
- feat: AccountCreateTransaction now supports both PrivateKey and PublicKey [#939](https://github.com/hiero-ledger/hiero-sdk-python/issues/939)
2020
- Added Acceptance Criteria section to Good First Issue template for better contributor guidance (#997)
2121
- Added __str__() to CustomRoyaltyFee and updated examples and tests accordingly (#986)
22+
- Support selecting specific node account ID(s) for queries and transactions and added `Network._get_node()` with updated execution flow (#362)
2223

2324
### Changed
2425

src/hiero_sdk_python/client/network.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,21 @@ def _select_node(self) -> _Node:
177177
self._node_index = (self._node_index + 1) % len(self.nodes)
178178
self.current_node = self.nodes[self._node_index]
179179
return self.current_node
180+
181+
def _get_node(self, account_id: AccountId) -> Optional[_Node]:
182+
"""
183+
Get a node matching the given account ID.
184+
185+
Args:
186+
account_id (AccountId): The account ID of the node to locate.
187+
188+
Returns:
189+
Optional[_Node]: The matching node, or None if not found.
190+
"""
191+
for node in self.nodes:
192+
if node._account_id == account_id:
193+
return node
194+
return None
180195

181196
def get_mirror_address(self) -> str:
182197
"""

src/hiero_sdk_python/executable.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from os import error
22
import time
3-
from typing import Callable, Optional, Any, TYPE_CHECKING
3+
from typing import Callable, Optional, Any, TYPE_CHECKING, List
44
import grpc
55
from abc import ABC, abstractmethod
66
from enum import IntEnum
77

88
from hiero_sdk_python.channels import _Channel
99
from hiero_sdk_python.exceptions import MaxAttemptsError
10+
from hiero_sdk_python.account.account_id import AccountId
1011
if TYPE_CHECKING:
1112
from hiero_sdk_python.client.client import Client
1213

@@ -75,6 +76,33 @@ def __init__(self):
7576
self._grpc_deadline = DEFAULT_GRPC_DEADLINE
7677
self.node_account_id = None
7778

79+
self.node_account_ids: List[AccountId] = []
80+
self._used_node_account_id: Optional[AccountId] = None
81+
self._node_account_ids_index: int = 0
82+
83+
def set_node_account_ids(self, node_account_ids: List[AccountId]):
84+
"""Select node account IDs for sending the request."""
85+
self.node_account_ids = node_account_ids
86+
return self
87+
88+
def set_node_account_id(self, node_account_id: AccountId):
89+
"""Convenience wrapper to set a single node account ID."""
90+
return self.set_node_account_ids([node_account_id])
91+
92+
def _select_node_account_id(self) -> Optional[AccountId]:
93+
"""Pick the current node from the list if available, otherwise None."""
94+
if self.node_account_ids:
95+
# Use modulo to cycle through the list
96+
selected = self.node_account_ids[self._node_account_ids_index % len(self.node_account_ids)]
97+
self._used_node_account_id = selected
98+
return selected
99+
return None
100+
101+
def _advance_node_index(self):
102+
"""Advance to the next node in the list."""
103+
if self.node_account_ids:
104+
self._node_account_ids_index += 1
105+
78106
@abstractmethod
79107
def _should_retry(self, response) -> _ExecutionState:
80108
"""
@@ -176,10 +204,20 @@ def _execute(self, client: "Client"):
176204
if attempt > 0 and current_backoff < self._max_backoff:
177205
current_backoff *= 2
178206

179-
# Set the node account id to the client's node account id
180-
node = client.network.current_node
207+
# Select preferred node if provided, fallback to client's default
208+
selected = self._select_node_account_id()
209+
210+
if selected is not None:
211+
node = client.network._get_node(selected)
212+
else:
213+
node = client.network.current_node
214+
215+
#Store for logging and receipts
181216
self.node_account_id = node._account_id
182-
217+
218+
# Advance to next node for the next attempt (if using explicit node list)
219+
self._advance_node_index()
220+
183221
# Create a channel wrapper from the client's channel
184222
channel = node._get_channel()
185223

@@ -210,6 +248,10 @@ def _execute(self, client: "Client"):
210248
case _ExecutionState.RETRY:
211249
# If we should retry, wait for the backoff period and try again
212250
err_persistant = status_error
251+
# If not using explicit node list, switch to next node for retry
252+
if not self.node_account_ids:
253+
node = client.network._select_node()
254+
logger.trace("Switched to a different node for retry", "error", err_persistant, "from node", self.node_account_id, "to node", node._account_id)
213255
_delay_for_attempt(self._get_request_id(), current_backoff, attempt, logger, err_persistant)
214256
continue
215257
case _ExecutionState.EXPIRED:
@@ -223,8 +265,11 @@ def _execute(self, client: "Client"):
223265
except grpc.RpcError as e:
224266
# Save the error
225267
err_persistant = f"Status: {e.code()}, Details: {e.details()}"
226-
node = client.network._select_node()
227-
logger.trace("Switched to a different node for the next attempt", "error", err_persistant, "from node", self.node_account_id, "to node", node._account_id)
268+
# If not using explicit node list, switch to next node for retry
269+
if not self.node_account_ids:
270+
node = client.network._select_node()
271+
logger.trace("Switched to a different node for the next attempt", "error", err_persistant, "from node", self.node_account_id, "to node", node._account_id)
272+
_delay_for_attempt(self._get_request_id(), current_backoff, attempt, logger, err_persistant)
228273
continue
229274

230275
logger.error("Exceeded maximum attempts for request", "requestId", self._get_request_id(), "last exception being", err_persistant)

src/hiero_sdk_python/query/query.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ def __init__(self) -> None:
5454
super().__init__()
5555

5656
self.timestamp: int = int(time.time())
57-
self.node_account_ids: List[AccountId] = []
5857
self.operator: Optional[Operator] = None
5958
self.node_index: int = 0
6059
self.payment_amount: Optional[Hbar] = None
@@ -106,11 +105,7 @@ def _before_execute(self, client: Client) -> None:
106105
Args:
107106
client: The client instance to use for execution
108107
"""
109-
if not self.node_account_ids:
110-
self.node_account_ids = client.get_node_account_ids()
111-
112108
self.operator = self.operator or client.operator
113-
self.node_account_ids = list(set(self.node_account_ids))
114109

115110
# If no payment amount was specified and payment is required for this query,
116111
# get the cost from the network and set it as the payment amount
@@ -379,3 +374,4 @@ def _is_payment_required(self) -> bool:
379374
bool: True if payment is required, False otherwise
380375
"""
381376
return True
377+

src/hiero_sdk_python/transaction/transaction.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import hashlib
2-
from typing import Optional
2+
from typing import List, Optional
33

44
from typing import TYPE_CHECKING
55

tests/unit/test_executable.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def test_retry_success_before_max_attempts():
4646
# First server gives 2 BUSY responses then OK on the 3rd try
4747
response_sequences = [[busy_response, busy_response, ok_response, receipt_response]]
4848

49-
with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
49+
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
5050
# Configure client to allow 3 attempts - should succeed on the last try
5151
client.max_attempts = 3
5252

@@ -70,7 +70,7 @@ def test_retry_failure_after_max_attempts():
7070

7171
response_sequences = [[busy_response, busy_response]]
7272

73-
with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
73+
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
7474
client.max_attempts = 2
7575

7676
transaction = (
@@ -112,7 +112,7 @@ def test_node_switching_after_single_grpc_error():
112112
[error],
113113
]
114114

115-
with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
115+
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
116116
transaction = (
117117
AccountCreateTransaction()
118118
.set_key(PrivateKey.generate().public_key())
@@ -149,7 +149,7 @@ def test_node_switching_after_multiple_grpc_errors():
149149
[ok_response, receipt_response],
150150
]
151151

152-
with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
152+
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
153153
transaction = (
154154
AccountCreateTransaction()
155155
.set_key(PrivateKey.generate().public_key())
@@ -185,7 +185,7 @@ def test_transaction_with_expired_error_not_retried():
185185
[error_response]
186186
]
187187

188-
with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
188+
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
189189
transaction = (
190190
AccountCreateTransaction()
191191
.set_key(PrivateKey.generate().public_key())
@@ -216,7 +216,7 @@ def test_transaction_with_fatal_error_not_retried():
216216
[error_response]
217217
]
218218

219-
with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
219+
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
220220
transaction = (
221221
AccountCreateTransaction()
222222
.set_key(PrivateKey.generate().public_key())
@@ -248,7 +248,7 @@ def test_exponential_backoff_retry():
248248
response_sequences = [[busy_response, busy_response, busy_response, ok_response, receipt_response]]
249249

250250
# Use a mock for time.sleep to capture the delay values
251-
with mock_hedera_servers(response_sequences) as client, patch('time.sleep') as mock_sleep:
251+
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep') as mock_sleep:
252252
client.max_attempts = 5
253253

254254
transaction = (
@@ -288,7 +288,7 @@ def test_retriable_error_does_not_switch_node():
288288
)
289289
)
290290
response_sequences = [[busy_response, ok_response, receipt_response]]
291-
with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
291+
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
292292
transaction = (
293293
AccountCreateTransaction()
294294
.set_key(PrivateKey.generate().public_key())
@@ -333,7 +333,7 @@ def test_topic_create_transaction_retry_on_busy():
333333
[busy_response, ok_response, receipt_response],
334334
]
335335

336-
with mock_hedera_servers(response_sequences) as client, patch('time.sleep') as mock_sleep:
336+
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep') as mock_sleep:
337337
client.max_attempts = 3
338338

339339
tx = (
@@ -367,7 +367,7 @@ def test_topic_create_transaction_fails_on_nonretriable_error():
367367
[error_response],
368368
]
369369

370-
with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
370+
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
371371
tx = (
372372
TopicCreateTransaction()
373373
.set_memo("Test with error")
@@ -400,7 +400,7 @@ def test_transaction_node_switching_body_bytes():
400400
[ok_response, receipt_response],
401401
]
402402

403-
with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
403+
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
404404
# We set the current node to 0
405405
client.network._node_index = 0
406406
client.network.current_node = client.network.nodes[0]
@@ -467,8 +467,9 @@ def test_query_retry_on_busy():
467467
[ok_response],
468468
]
469469

470-
with mock_hedera_servers(response_sequences) as client, patch('time.sleep') as mock_sleep:
470+
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep') as mock_sleep:
471471
# We set the current node to the first node so we are sure it will return BUSY response
472+
client.network._node_index = 0
472473
client.network.current_node = client.network.nodes[0]
473474

474475
query = CryptoGetAccountBalanceQuery()

tests/unit/test_query_nodes.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import pytest
2+
from hiero_sdk_python.query.query import Query
3+
from hiero_sdk_python.account.account_id import AccountId
4+
5+
def test_set_single_node_account_id():
6+
q = Query()
7+
node = AccountId(0, 0, 3)
8+
9+
q.set_node_account_id(node)
10+
11+
assert q.node_account_ids == [node]
12+
assert q._used_node_account_id is None # not selected until execution
13+
14+
def test_set_multiple_node_account_ids():
15+
q = Query()
16+
nodes = [AccountId(0, 0, 3), AccountId(0, 0, 4)]
17+
18+
q.set_node_account_ids(nodes)
19+
20+
assert q.node_account_ids == nodes
21+
assert q._used_node_account_id is None
22+
23+
def test_select_node_account_id():
24+
q = Query()
25+
nodes = [AccountId(0, 0, 3), AccountId(0, 0, 4)]
26+
q.set_node_account_ids(nodes)
27+
28+
selected = q._select_node_account_id()
29+
30+
assert selected == nodes[0]
31+
assert q._used_node_account_id == nodes[0]
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import pytest
2+
from hiero_sdk_python.transaction.transaction import Transaction
3+
from hiero_sdk_python.account.account_id import AccountId
4+
5+
6+
class DummyTransaction(Transaction):
7+
"""
8+
Minimal subclass of Transaction for testing.
9+
Transaction is abstract (requires build methods), so we stub them out.
10+
"""
11+
def __init__(self):
12+
super().__init__()
13+
14+
def build_base_transaction_body(self):
15+
return None # stub
16+
17+
def _make_request(self):
18+
return None # stub
19+
20+
def _get_method(self):
21+
return None # stub
22+
23+
24+
def test_set_single_node_account_id():
25+
txn = DummyTransaction()
26+
node = AccountId(0, 0, 3)
27+
28+
txn.set_node_account_id(node)
29+
30+
assert txn.node_account_ids == [node]
31+
assert txn._used_node_account_id is None
32+
33+
34+
def test_set_multiple_node_account_ids():
35+
txn = DummyTransaction()
36+
nodes = [AccountId(0, 0, 3), AccountId(0, 0, 4)]
37+
38+
txn.set_node_account_ids(nodes)
39+
40+
assert txn.node_account_ids == nodes
41+
assert txn._used_node_account_id is None
42+
43+
44+
def test_select_node_account_id():
45+
txn = DummyTransaction()
46+
nodes = [AccountId(0, 0, 3), AccountId(0, 0, 4)]
47+
txn.set_node_account_ids(nodes)
48+
49+
selected = txn._select_node_account_id()
50+
51+
assert selected == nodes[0]
52+
assert txn._used_node_account_id == nodes[0]

0 commit comments

Comments
 (0)