Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
59 changes: 52 additions & 7 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,20 +186,65 @@
- name: Code Quality Checks
run: ./gradlew qualityCheck :examples:qualityCheck --continue --rerun-tasks

- name: Run Unit and Integration Tests
env:
OPERATOR_KEY: "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137"
OPERATOR_ID: "0.0.2"
HEDERA_NETWORK: "localhost"
run: |
./gradlew -POPERATOR_ID=$OPERATOR_ID -POPERATOR_KEY=$OPERATOR_KEY -PHEDERA_NETWORK=$HEDERA_NETWORK :aggregation:testCodeCoverageReport
- name: Run Unit and Integration Tests (excluding NodeUpdateAccountIdIntegrationTest)
run: |
./gradlew \
-POPERATOR_ID=$OPERATOR_ID \
-POPERATOR_KEY=$OPERATOR_KEY \
-PHEDERA_NETWORK=$HEDERA_NETWORK \
:aggregation:testCodeCoverageReport \
--exclude-test "*NodeUpdateAccountIdIntegrationTest"

- name: Upload coverage to Codecov
if: ${{ github.event_name == 'push' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') }}
uses: codecov/codecov-action@5c47607acb93fed5485fdbf7232e8a31425f672a # v5.0.2
with:
files: gradle/aggregation/build/reports/jacoco/testCodeCoverageReport/testCodeCoverageReport.xml

test-node-update-account-id:
name: NodeUpdateAccountIdIntegrationTest (dual solo)
runs-on: hiero-client-sdk-linux-medium
needs:
- build
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911

- name: Checkout Code
uses: actions/checkout@v4

- name: Setup NodeJS
uses: actions/setup-node@v4
with:
node-version: 18

- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21.0.6"

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5

Check warning on line 228 in .github/workflows/build.yml

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

.github/workflows/build.yml#L228

An action sourced from a third-party repository on GitHub is not pinned to a full length commit SHA. Pinning an action to a full length commit SHA is currently the only way to use an action as an immutable release.

- name: Prepare Hiero Solo (DUAL MODE)
id: solo
uses: hiero-ledger/hiero-solo-action@a5fdd9282834d985ae6928d9920404e026333078
with:
installMirrorNode: true

- name: Build SDK
run: ./gradlew assemble

- name: Run Only NodeUpdateAccountIdIntegrationTest
env:
OPERATOR_KEY: "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137"
OPERATOR_ID: "0.0.2"
HEDERA_NETWORK: "localhost"
run: |
./gradlew integrationTest --tests "*NodeUpdateAccountIdIntegrationTest"


run-examples:
name: Run Examples
runs-on: hiero-client-sdk-linux-medium
Expand Down
35 changes: 35 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,41 @@ 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);
System.out.println("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());
System.out.println("address book size: " + addressBook.nodeAddresses.size());

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

logger.info("Address book update completed successfully");
System.out.println("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
88 changes: 74 additions & 14 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 @@ -597,7 +613,18 @@ void setNodesFromNodeAccountIds(Client client) {
if (nodeAccountIds.size() == 1) {
var nodeProxies = client.network.getNodeProxies(nodeAccountIds.get(0));
if (nodeProxies == null || nodeProxies.isEmpty()) {
throw new IllegalStateException("Account ID did not map to valid node in the client's network");
logger.warn("Node {} not found in network, fetching latest address book", nodeAccountIds.get(0));

try {
client.updateNetworkFromAddressBook(); // Synchronous update
nodeProxies = client.network.getNodeProxies(nodeAccountIds.get(0));
} catch (Exception e) {
logger.error("Failed to update address book", e);
}

if (nodeProxies == null || nodeProxies.isEmpty()) {
throw new IllegalStateException("nodeProxies is null or empty");
}
}

nodes.addAll(nodeProxies).shuffle();
Expand Down Expand Up @@ -679,10 +706,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 +753,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 +777,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 +795,26 @@ 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(); // Advance to next node before retrying

// Handle INVALID_NODE_ACCOUNT after advancing (matches Go SDK's
// executionStateRetryWithAnotherNode)
if (status == Status.INVALID_NODE_ACCOUNT) {
logger.trace(
"Received INVALID_NODE_ACCOUNT; updating address book and marking node {} as unhealthy, attempt #{}",
grpcRequest.getNode().getAccountId(),
attempt);
// Schedule async address book update (matches Go's defer
// client._UpdateAddressBook())
client.updateNetworkFromAddressBook();
// Mark this node as unhealthy
client.network.increaseBackoff(grpcRequest.getNode());
}

executeAsyncInternal(
client,
attempt + 1,
Expand All @@ -779,6 +823,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 +914,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 +1022,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 +1054,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