Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f8cf1a6
initial logic for changing node account id
emiliyank Oct 21, 2025
0c6c579
remove client property from GrpcRequest
emiliyank Oct 21, 2025
b04c0c2
add NodeUpdateAccountIdIntegrationTest
emiliyank Oct 26, 2025
017dd5e
hip 1299 - in Executable handle status INVALID_NODE_ACCOUNT_ID
emiliyank Nov 3, 2025
c25d9cd
hip 1299 - add integration test to update AddressBook
emiliyank Nov 3, 2025
9bcfbd3
hip 1299 - switch error from INVALID_NODE_ACCOUNT_ID to INVALID_NODE_…
emiliyank Nov 4, 2025
ba6351c
Merge branch 'main' into feature/hip-1299-changing-node-account-id
emiliyank Nov 5, 2025
c78f359
hip 1299 - use new method Client.updateNetworkFromAddressBook()
emiliyank Nov 6, 2025
ae7dcc3
Merge remote-tracking branch 'origin/feature/hip-1299-changing-node-a…
emiliyank Nov 6, 2025
875d06f
hip-1299 add more integration tests
emiliyank Nov 6, 2025
0f35473
initial logic for changing node account id
emiliyank Oct 21, 2025
d6edab7
remove client property from GrpcRequest
emiliyank Oct 21, 2025
a2c75ff
add NodeUpdateAccountIdIntegrationTest
emiliyank Oct 26, 2025
7b273cb
hip 1299 - in Executable handle status INVALID_NODE_ACCOUNT_ID
emiliyank Nov 3, 2025
99810ac
hip 1299 - add integration test to update AddressBook
emiliyank Nov 3, 2025
cd3870e
hip 1299 - switch error from INVALID_NODE_ACCOUNT_ID to INVALID_NODE_…
emiliyank Nov 4, 2025
b7388d4
hip 1299 - use new method Client.updateNetworkFromAddressBook()
emiliyank Nov 6, 2025
84474ac
hip-1299 add more integration tests
emiliyank Nov 6, 2025
e1f2dce
move all dab tests to NodeUpdateTransactionIntegrationTest
emiliyank Nov 11, 2025
06d46a9
Merge remote-tracking branch 'origin/feature/hip-1299-changing-node-a…
emiliyank Nov 11, 2025
6249fdd
remove unused class NodeUpdateAccountIdIntegrationTest
emiliyank Nov 11, 2025
63c79e9
use consensus node v0.68 on solo-action
emiliyank Nov 12, 2025
2ebecb1
add --rerun-tasks on gradlew
emiliyank Nov 13, 2025
f60ea70
add --rerun-tasks on :aggregation:testCodeCoverageReport
emiliyank Nov 13, 2025
76c8add
revert unused changes in Executable
emiliyank Nov 13, 2025
3064770
Merge branch 'main' into feature/hip-1299-changing-node-account-id
emiliyank Dec 1, 2025
073a3b3
fix: advance node on server error in async execution
emiliyank Dec 2, 2025
cda1929
refactor NodeUpdateTransactionIntegrationTest to fix Codacy warning
emiliyank Dec 2, 2025
8da6d3a
bump solo-action version
emiliyank Dec 4, 2025
3e06003
build.yml change order of PskipNodeUpdateTest
emiliyank Dec 4, 2025
e562e98
build.yml bump mirrorNodeVersion
emiliyank Dec 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,10 @@ jobs:

- name: Prepare Hiero Solo
id: solo
uses: hiero-ledger/hiero-solo-action@dd0048139ef1e40fd6067f01bf94eb42a67294f4 # v0.15
uses: hiero-ledger/hiero-solo-action@fbca3e7a99ce9aa8a250563a81187abe115e0dad # v0.16
with:
installMirrorNode: true
hieroVersion: v0.69.0-alpha.1
hieroVersion: "v0.69.0-alpha.1"

- name: Build SDK
run: ./gradlew assemble
Expand All @@ -197,15 +197,17 @@ jobs:
-POPERATOR_ID=$OPERATOR_ID \
-POPERATOR_KEY=$OPERATOR_KEY \
-PHEDERA_NETWORK=$HEDERA_NETWORK \
-PskipNodeUpdateTest=true \
test \
testIntegration \
-PskipNodeUpdateTest=true
--rerun-tasks

./gradlew \
-POPERATOR_ID=$OPERATOR_ID \
-POPERATOR_KEY=$OPERATOR_KEY \
-PHEDERA_NETWORK=$HEDERA_NETWORK \
:aggregation:testCodeCoverageReport
:aggregation:testCodeCoverageReport \
--rerun-tasks

- name: Upload coverage to Codecov
if: ${{ github.event_name == 'push' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') }}
Expand Down Expand Up @@ -245,10 +247,12 @@ jobs:

- name: Prepare Hiero Solo (DUAL MODE)
id: solo
uses: hiero-ledger/hiero-solo-action@dd0048139ef1e40fd6067f01bf94eb42a67294f4 # v0.15
uses: hiero-ledger/hiero-solo-action@fbca3e7a99ce9aa8a250563a81187abe115e0dad # v0.16
with:
installMirrorNode: true
dualMode: true
hieroVersion: "v0.68.0"
mirrorNodeVersion: "v0.142.0"

- name: Build SDK
run: ./gradlew assemble
Expand All @@ -265,6 +269,7 @@ jobs:
-PHEDERA_NETWORK=$HEDERA_NETWORK \
testIntegration \
--tests "*NodeUpdateTransactionIntegrationTest"
--rerun-tasks

run-examples:
name: Run Examples
Expand Down Expand Up @@ -299,10 +304,11 @@ jobs:

- name: Prepare Hiero Solo
id: solo
uses: hiero-ledger/hiero-solo-action@dd0048139ef1e40fd6067f01bf94eb42a67294f4 # v0.15
uses: hiero-ledger/hiero-solo-action@fbca3e7a99ce9aa8a250563a81187abe115e0dad # v0.16
with:
installMirrorNode: true
hieroVersion: v0.69.0-alpha.1
hieroVersion: "v0.69.0-alpha.1"
mirrorNodeVersion: "v0.142.0"

- name: Build SDK
run: ./gradlew assemble
Expand Down
32 changes: 32 additions & 0 deletions sdk/src/main/java/com/hedera/hashgraph/sdk/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -1404,6 +1404,38 @@ public synchronized Client setNetworkUpdatePeriod(Duration networkUpdatePeriod)
return this;
}

/**
* Trigger an immediate address book update to refresh the client's network with the latest node information.
* This is useful when encountering INVALID_NODE_ACCOUNT_ID errors to ensure subsequent transactions
* use the correct node account IDs.
*
* @return {@code this}
*/
public synchronized Client updateNetworkFromAddressBook() {
try {
var fileId = FileId.getAddressBookFileIdFor(this.shard, this.realm);

logger.debug("Fetching address book from file {}", fileId);

// Execute synchronously - no async complexity
var addressBook = new AddressBookQuery().setFileId(fileId).execute(this); // ← Synchronous!

logger.debug("Received address book with {} nodes", addressBook.nodeAddresses.size());

// Update the network
this.setNetworkFromAddressBook(addressBook);

logger.info("Address book update completed successfully");

} catch (TimeoutException e) {
logger.warn("Failed to fetch address book: {}", e.getMessage());
} catch (Exception e) {
logger.warn("Failed to update address book", e);
}

return this;
}

public Logger getLogger() {
return this.logger;
}
Expand Down
74 changes: 61 additions & 13 deletions sdk/src/main/java/com/hedera/hashgraph/sdk/Executable.java
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ public O execute(Client client, Duration timeout) throws TimeoutException, Prech
if (node.channelFailedToConnect(timeoutTime)) {
logger.trace("Failed to connect channel for node {} for request #{}", node.getAccountId(), attempt);
lastException = grpcRequest.reactToConnectionFailure();
advanceRequest(); // Advance to next node before retrying
continue;
}

Expand All @@ -425,6 +426,7 @@ public O execute(Client client, Duration timeout) throws TimeoutException, Prech

if (response == null) {
if (grpcRequest.shouldRetryExceptionally(lastException)) {
advanceRequest(); // Advance to next node before retrying
continue;
} else {
throw new RuntimeException(lastException);
Expand All @@ -433,11 +435,24 @@ public O execute(Client client, Duration timeout) throws TimeoutException, Prech

var status = mapResponseStatus(response);
var executionState = getExecutionState(status, response);
grpcRequest.handleResponse(response, status, executionState);
grpcRequest.handleResponse(response, status, executionState, client);

switch (executionState) {
case SERVER_ERROR:
lastException = grpcRequest.mapStatusException();
advanceRequest(); // Advance to next node before retrying

// Handle INVALID_NODE_ACCOUNT after advancing
if (status == Status.INVALID_NODE_ACCOUNT) {
logger.trace(
"Received INVALID_NODE_ACCOUNT; updating address book and marking node {} as unhealthy, attempt #{}",
node.getAccountId(),
attempt);
// Schedule async address book update
client.updateNetworkFromAddressBook();
// Mark this node as unhealthy
client.network.increaseBackoff(node);
}
continue;
case RETRY:
// Response is not ready yet from server, need to wait.
Expand All @@ -446,6 +461,7 @@ public O execute(Client client, Duration timeout) throws TimeoutException, Prech
currentTimeout = Duration.between(Instant.now(), timeoutTime);
delay(Math.min(currentTimeout.toMillis(), grpcRequest.getDelay()));
}
advanceRequest(); // Advance to next node before retrying
continue;
case REQUEST_ERROR:
throw grpcRequest.mapStatusException();
Expand Down Expand Up @@ -679,10 +695,9 @@ Node getNodeForExecute(int attempt) {
private ProtoRequestT getRequestForExecute() {
var request = makeRequest();

// advance the internal index
// non-free queries and transactions map to more than 1 actual transaction and this will cause
// the next invocation of makeRequest to return the _next_ transaction
advanceRequest();
// NOTE: advanceRequest() is now called explicitly in the retry logic
// after we determine that a retry is needed
// where node advancement happens AFTER error detection, not before

return request;
}
Expand Down Expand Up @@ -727,6 +742,7 @@ private void executeAsyncInternal(
.thenAccept(connectionFailed -> {
if (connectionFailed) {
var connectionException = grpcRequest.reactToConnectionFailure();
advanceRequest(); // Advance to next node before retrying
executeAsyncInternal(
client,
attempt + 1,
Expand All @@ -750,6 +766,7 @@ private void executeAsyncInternal(

if (grpcRequest.shouldRetryExceptionally(error)) {
// the transaction had a network failure reaching Hedera
advanceRequest(); // Advance to next node before retrying
executeAsyncInternal(
client,
attempt + 1,
Expand All @@ -767,10 +784,25 @@ private void executeAsyncInternal(

var status = mapResponseStatus(response);
var executionState = getExecutionState(status, response);
grpcRequest.handleResponse(response, status, executionState);
grpcRequest.handleResponse(response, status, executionState, client);

switch (executionState) {
case SERVER_ERROR:
advanceRequest();
if (status == com.hedera.hashgraph.sdk.Status.INVALID_NODE_ACCOUNT) {
if (logger.isEnabledForLevel(LogLevel.TRACE)) {
logger.trace(
"Received INVALID_NODE_ACCOUNT; updating address book and marking node {} as unhealthy, attempt #{}",
grpcRequest
.getNode()
.getAccountId(),
attempt);
}
// Schedule async address book update
client.updateNetworkFromAddressBook();
// Mark this node as unhealthy
client.network.increaseBackoff(grpcRequest.getNode());
}
executeAsyncInternal(
client,
attempt + 1,
Expand All @@ -779,6 +811,7 @@ private void executeAsyncInternal(
Duration.between(Instant.now(), timeoutTime));
break;
case RETRY:
advanceRequest(); // Advance to next node before retrying
Delayer.delayFor(
(attempt < maxAttempts) ? grpcRequest.getDelay() : 0,
client.executor)
Expand Down Expand Up @@ -869,6 +902,10 @@ ExecutionState getExecutionState(Status status, ResponseT response) {
return ExecutionState.SERVER_ERROR;
case BUSY:
return ExecutionState.RETRY;
case INVALID_NODE_ACCOUNT:
return ExecutionState
.SERVER_ERROR; // immediately retry with next node without delay. This occurs when a node's
// account ID has changed
case OK:
return ExecutionState.SUCCESS;
default:
Expand Down Expand Up @@ -973,8 +1010,12 @@ O mapResponse() {
return Executable.this.mapResponse(response, node.getAccountId(), request);
}

void handleResponse(ResponseT response, Status status, ExecutionState executionState) {
node.decreaseBackoff();
void handleResponse(ResponseT response, Status status, ExecutionState executionState, @Nullable Client client) {
// Note: For INVALID_NODE_ACCOUNT, we don't mark the node as unhealthy here
// because we need to do it AFTER advancing the request, to match Go SDK behavior
if (status != Status.INVALID_NODE_ACCOUNT) {
node.decreaseBackoff();
}

this.response = Executable.this.responseListener.apply(response);
this.responseStatus = status;
Expand All @@ -1001,11 +1042,18 @@ void handleResponse(ResponseT response, Status status, ExecutionState executionS
responseStatus);
verboseLog(node);
}
case SERVER_ERROR -> logger.warn(
"Problem submitting request to node {} for attempt #{}, retry with new node: {}",
node.getAccountId(),
attempt,
responseStatus);
case SERVER_ERROR -> {
// Note: INVALID_NODE_ACCOUNT is handled after advanceRequest() in execute methods
// to match Go SDK's executionStateRetryWithAnotherNode behavior
if (status != Status.INVALID_NODE_ACCOUNT) {
logger.warn(
"Problem submitting request to node {} for attempt #{}, retry with new node: {}",
node.getAccountId(),
attempt,
responseStatus);
verboseLog(node);
}
}
default -> {}
}
}
Expand Down
65 changes: 65 additions & 0 deletions sdk/src/test/java/com/hedera/hashgraph/sdk/ExecutableTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
Expand Down Expand Up @@ -511,6 +512,9 @@ void shouldRetryReturnsCorrectStates() {
.isEqualTo(ExecutionState.SERVER_ERROR);
assertThat(tx.getExecutionState(Status.PLATFORM_NOT_ACTIVE, null)).isEqualTo(ExecutionState.SERVER_ERROR);
assertThat(tx.getExecutionState(Status.BUSY, null)).isEqualTo(ExecutionState.RETRY);
// INVALID_NODE_ACCOUNT now returns SERVER_ERROR to match Go SDK's executionStateRetryWithAnotherNode
// which immediately retries with another node without delay
assertThat(tx.getExecutionState(Status.INVALID_NODE_ACCOUNT, null)).isEqualTo(ExecutionState.SERVER_ERROR);
assertThat(tx.getExecutionState(Status.OK, null)).isEqualTo(ExecutionState.SUCCESS);
assertThat(tx.getExecutionState(Status.ACCOUNT_DELETED, null)).isEqualTo(ExecutionState.REQUEST_ERROR);
}
Expand All @@ -526,6 +530,67 @@ void shouldSetMaxRetry() {
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> tx.setMaxRetry(0));
}

@Test
void shouldMarkNodeAsUnusableOnInvalidNodeAccountId() throws PrecheckStatusException, TimeoutException {
when(node3.isHealthy()).thenReturn(true);
when(node3.channelFailedToConnect()).thenReturn(false);
when(node4.isHealthy()).thenReturn(true);
when(node4.channelFailedToConnect()).thenReturn(false);
when(node5.isHealthy()).thenReturn(true);
when(node5.channelFailedToConnect()).thenReturn(false);

var tx = new DummyTransaction() {
@Override
Status mapResponseStatus(com.hedera.hashgraph.sdk.proto.TransactionResponse response) {
return Status.INVALID_NODE_ACCOUNT;
}
};
var nodeAccountIds = Arrays.asList(new AccountId(0, 0, 3), new AccountId(0, 0, 4), new AccountId(0, 0, 5));
tx.setNodeAccountIds(nodeAccountIds);

var txResp = com.hedera.hashgraph.sdk.proto.TransactionResponse.newBuilder()
.setNodeTransactionPrecheckCode(ResponseCodeEnum.INVALID_NODE_ACCOUNT)
.build();

tx.blockingUnaryCall = (grpcRequest) -> txResp;

// This should retry with a different node due to INVALID_NODE_ACCOUNT
assertThatExceptionOfType(MaxAttemptsExceededException.class).isThrownBy(() -> tx.execute(client));

// Verify that increaseBackoff was called on the network for each node that returned INVALID_NODE_ACCOUNT
verify(network, atLeastOnce()).increaseBackoff(any(Node.class));
}

@Test
void shouldTriggerAddressBookUpdateOnInvalidNodeAccountId() throws PrecheckStatusException, TimeoutException {
when(node3.isHealthy()).thenReturn(true);
when(node3.channelFailedToConnect()).thenReturn(false);

var tx = new DummyTransaction() {
@Override
Status mapResponseStatus(com.hedera.hashgraph.sdk.proto.TransactionResponse response) {
return Status.INVALID_NODE_ACCOUNT;
}
};
var nodeAccountIds = Arrays.asList(new AccountId(0, 0, 3));
tx.setNodeAccountIds(nodeAccountIds);

var txResp = com.hedera.hashgraph.sdk.proto.TransactionResponse.newBuilder()
.setNodeTransactionPrecheckCode(ResponseCodeEnum.INVALID_NODE_ACCOUNT)
.build();

tx.blockingUnaryCall = (grpcRequest) -> txResp;

// This should trigger address book update due to INVALID_NODE_ACCOUNT
assertThatExceptionOfType(MaxAttemptsExceededException.class).isThrownBy(() -> tx.execute(client));

// Verify that increaseBackoff was called (node marking)
verify(network, atLeastOnce()).increaseBackoff(any(Node.class));

// Note: We can't easily test the address book update in this unit test since it's async
// and involves network calls. The integration test would be more appropriate for that.
}

static class DummyTransaction<T extends Transaction<T>>
extends Executable<
T,
Expand Down
Loading
Loading