com.hedera.hashgraph.sdk.Executable Maven / Gradle / Ivy
Show all versions of sdk-full Show documentation
/*-
*
* Hedera Java SDK
*
* Copyright (C) 2020 - 2024 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package com.hedera.hashgraph.sdk;
import static com.hedera.hashgraph.sdk.FutureConverter.toCompletableFuture;
import com.google.common.annotations.VisibleForTesting;
import com.google.protobuf.MessageLite;
import com.hedera.hashgraph.sdk.logger.LogLevel;
import com.hedera.hashgraph.sdk.logger.Logger;
import io.grpc.CallOptions;
import io.grpc.ClientCall;
import io.grpc.MethodDescriptor;
import io.grpc.Status.Code;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.ClientCalls;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
import org.bouncycastle.util.encoders.Hex;
/**
* Abstract base utility class.
*
* @param the sdk request
* @param the proto request
* @param the response
* @param the O type
*/
abstract class Executable {
@SuppressWarnings("java:S2245")
protected static final Random random = new Random();
static final Pattern RST_STREAM = Pattern
.compile(".*\\brst[^0-9a-zA-Z]stream\\b.*", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
/**
* The maximum times execution will be attempted
*/
@Nullable
protected Integer maxAttempts = null;
/**
* The maximum amount of time to wait between retries
*/
@Nullable
protected Duration maxBackoff = null;
/**
* The minimum amount of time to wait between retries
*/
@Nullable
protected Duration minBackoff = null;
/**
* List of account IDs for nodes with which execution will be attempted.
*/
protected LockableList nodeAccountIds = new LockableList<>();
/**
* List of healthy and unhealthy nodes with which execution will be attempted.
*/
protected LockableList nodes = new LockableList<>();
/**
* Indicates if the request has been attempted to be sent to all nodes
*/
protected boolean attemptedAllNodes = false;
/**
* The timeout for each execution attempt
*/
protected Duration grpcDeadline;
protected Logger logger;
private java.util.function.Function requestListener;
// Lambda responsible for executing synchronous gRPC requests. Pluggable for unit testing.
@VisibleForTesting
Function blockingUnaryCall =
(grpcRequest) -> ClientCalls.blockingUnaryCall(grpcRequest.createCall(), grpcRequest.getRequest());
private java.util.function.Function responseListener;
Executable() {
requestListener = request -> {
if (logger.isEnabledForLevel(LogLevel.TRACE)) {
logger.trace("Sent protobuf {}", Hex.toHexString(request.toByteArray()));
}
return request;
};
responseListener = response -> {
if (logger.isEnabledForLevel(LogLevel.TRACE)) {
logger.trace("Received protobuf {}", Hex.toHexString(response.toByteArray()));
}
return response;
};
}
/**
* When execution is attempted, a single attempt will time out when this deadline is reached. (The SDK may
* subsequently retry the execution.)
*
* @return The timeout for each execution attempt
*/
public final Duration grpcDeadline() {
return grpcDeadline;
}
/**
* When execution is attempted, a single attempt will timeout when this deadline is reached. (The SDK may
* subsequently retry the execution.)
*
* @param grpcDeadline The timeout for each execution attempt
* @return {@code this}
*/
public final SdkRequestT setGrpcDeadline(Duration grpcDeadline) {
this.grpcDeadline = Objects.requireNonNull(grpcDeadline);
// noinspection unchecked
return (SdkRequestT) this;
}
/**
* The maximum amount of time to wait between retries
*
* @return maxBackoff
*/
public final Duration getMaxBackoff() {
return maxBackoff != null ? maxBackoff : Client.DEFAULT_MAX_BACKOFF;
}
/**
* The maximum amount of time to wait between retries. Every retry attempt will increase the wait time exponentially
* until it reaches this time.
*
* @param maxBackoff The maximum amount of time to wait between retries
* @return {@code this}
*/
public final SdkRequestT setMaxBackoff(Duration maxBackoff) {
if (maxBackoff == null || maxBackoff.toNanos() < 0) {
throw new IllegalArgumentException("maxBackoff must be a positive duration");
} else if (maxBackoff.compareTo(getMinBackoff()) < 0) {
throw new IllegalArgumentException("maxBackoff must be greater than or equal to minBackoff");
}
this.maxBackoff = maxBackoff;
// noinspection unchecked
return (SdkRequestT) this;
}
/**
* The minimum amount of time to wait between retries
*
* @return minBackoff
*/
public final Duration getMinBackoff() {
return minBackoff != null ? minBackoff : Client.DEFAULT_MIN_BACKOFF;
}
/**
* The minimum amount of time to wait between retries. When retrying, the delay will start at this time and increase
* exponentially until it reaches the maxBackoff.
*
* @param minBackoff The minimum amount of time to wait between retries
* @return {@code this}
*/
public final SdkRequestT setMinBackoff(Duration minBackoff) {
if (minBackoff == null || minBackoff.toNanos() < 0) {
throw new IllegalArgumentException("minBackoff must be a positive duration");
} else if (minBackoff.compareTo(getMaxBackoff()) > 0) {
throw new IllegalArgumentException("minBackoff must be less than or equal to maxBackoff");
}
this.minBackoff = minBackoff;
// noinspection unchecked
return (SdkRequestT) this;
}
/**
* @return Number of errors before execution will fail.
* @deprecated Use {@link #getMaxAttempts()} instead.
*/
@java.lang.Deprecated
public final int getMaxRetry() {
return getMaxAttempts();
}
/**
* @param count Number of errors before execution will fail
* @return {@code this}
* @deprecated Use {@link #setMaxAttempts(int)} instead.
*/
@java.lang.Deprecated
public final SdkRequestT setMaxRetry(int count) {
return setMaxAttempts(count);
}
/**
* Get the maximum times execution will be attempted.
*
* @return Number of errors before execution will fail.
*/
public final int getMaxAttempts() {
return maxAttempts != null ? maxAttempts : Client.DEFAULT_MAX_ATTEMPTS;
}
/**
* Set the maximum times execution will be attempted.
*
* @param maxAttempts Execution will fail after this many errors.
* @return {@code this}
*/
public final SdkRequestT setMaxAttempts(int maxAttempts) {
if (maxAttempts <= 0) {
throw new IllegalArgumentException("maxAttempts must be greater than zero");
}
this.maxAttempts = maxAttempts;
// noinspection unchecked
return (SdkRequestT) this;
}
/**
* Get the list of account IDs for nodes with which execution will be attempted.
*
* @return the list of account IDs
*/
@Nullable
public final List getNodeAccountIds() {
if (!nodeAccountIds.isEmpty()) {
return new ArrayList<>(nodeAccountIds.getList());
}
return null;
}
/**
* Set the account IDs of the nodes that this transaction will be submitted to.
*
* Providing an explicit node account ID interferes with client-side load balancing of the network. By default, the
* SDK will pre-generate a transaction for 1/3 of the nodes on the network. If a node is down, busy, or otherwise
* reports a fatal error, the SDK will try again with a different node.
*
* @param nodeAccountIds The list of node AccountIds to be set
* @return {@code this}
*/
public SdkRequestT setNodeAccountIds(List nodeAccountIds) {
this.nodeAccountIds.setList(nodeAccountIds).setLocked(true);
// noinspection unchecked
return (SdkRequestT) this;
}
/**
* Set a callback that will be called right before the request is sent. As input, the callback will receive the
* protobuf of the request, and the callback should return the request protobuf. This means the callback has an
* opportunity to read, copy, or modify the request that will be sent.
*
* @param requestListener The callback to use
* @return {@code this}
*/
public final SdkRequestT setRequestListener(UnaryOperator requestListener) {
this.requestListener = Objects.requireNonNull(requestListener);
return (SdkRequestT) this;
}
/**
* Set a callback that will be called right before the response is returned. As input, the callback will receive the
* protobuf of the response, and the callback should return the response protobuf. This means the callback has an
* opportunity to read, copy, or modify the response that will be read.
*
* @param responseListener The callback to use
* @return {@code this}
*/
public final SdkRequestT setResponseListener(UnaryOperator responseListener) {
this.responseListener = Objects.requireNonNull(responseListener);
return (SdkRequestT) this;
}
/**
* Set the logger
*
* @param logger the new logger
* @return {@code this}
*/
public SdkRequestT setLogger(Logger logger) {
this.logger = logger;
return (SdkRequestT) this;
}
void checkNodeAccountIds() {
if (nodeAccountIds.isEmpty()) {
throw new IllegalStateException("Request node account IDs were not set before executing");
}
}
abstract void onExecute(Client client) throws TimeoutException, PrecheckStatusException;
abstract CompletableFuture onExecuteAsync(Client client);
void mergeFromClient(Client client) {
if (maxAttempts == null) {
maxAttempts = client.getMaxAttempts();
}
if (maxBackoff == null) {
maxBackoff = client.getMaxBackoff();
}
if (minBackoff == null) {
minBackoff = client.getMinBackoff();
}
if (grpcDeadline == null) {
grpcDeadline = client.getGrpcDeadline();
}
}
private void delay(long delay) {
if (delay <= 0) {
return;
}
try {
if (delay > 0) {
if (logger.isEnabledForLevel(LogLevel.DEBUG)) {
logger.debug("Sleeping for: " + delay + " | Thread name: " + Thread.currentThread().getName());
}
Thread.sleep(delay);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
/**
* Execute this transaction or query
*
* @param client The client with which this will be executed.
* @return Result of execution
* @throws TimeoutException when the transaction times out
* @throws PrecheckStatusException when the precheck fails
*/
public O execute(Client client) throws TimeoutException, PrecheckStatusException {
return execute(client, client.getRequestTimeout());
}
/**
* Execute this transaction or query with a timeout
*
* @param client The client with which this will be executed.
* @param timeout The timeout after which the execution attempt will be cancelled.
* @return Result of execution
* @throws TimeoutException when the transaction times out
* @throws PrecheckStatusException when the precheck fails
*/
public O execute(Client client, Duration timeout) throws TimeoutException, PrecheckStatusException {
Throwable lastException = null;
// If the logger on the request is not set, use the logger in client
// (if set, otherwise do not use logger)
if (this.logger == null) {
this.logger = client.getLogger();
}
mergeFromClient(client);
onExecute(client);
checkNodeAccountIds();
setNodesFromNodeAccountIds(client);
var timeoutTime = Instant.now().plus(timeout);
for (int attempt = 1; /* condition is done within loop */ ; attempt++) {
if (attempt > maxAttempts) {
throw new MaxAttemptsExceededException(lastException);
}
Duration currentTimeout = Duration.between(Instant.now(), timeoutTime);
if (currentTimeout.isNegative() || currentTimeout.isZero()) {
throw new TimeoutException();
}
GrpcRequest grpcRequest = new GrpcRequest(client.network, attempt, currentTimeout);
Node node = grpcRequest.getNode();
ResponseT response = null;
// If we get an unhealthy node here, we've cycled through all the "good" nodes that have failed
// and have no choice but to try a bad one.
if (!node.isHealthy()) {
delay(node.getRemainingTimeForBackoff());
}
if (node.channelFailedToConnect(timeoutTime)) {
logger.trace("Failed to connect channel for node {} for request #{}", node.getAccountId(), attempt);
lastException = grpcRequest.reactToConnectionFailure();
continue;
}
currentTimeout = Duration.between(Instant.now(), timeoutTime);
grpcRequest.setGrpcDeadline(currentTimeout);
try {
response = blockingUnaryCall.apply(grpcRequest);
logTransaction(this.getTransactionIdInternal(), client, node, false, attempt, response, null);
} catch (Throwable e) {
if (e instanceof StatusRuntimeException) {
StatusRuntimeException statusRuntimeException = (StatusRuntimeException) e;
if (statusRuntimeException.getStatus().getCode().equals(Code.DEADLINE_EXCEEDED)) {
throw new TimeoutException();
}
}
lastException = e;
logTransaction(this.getTransactionIdInternal(), client, node, false, attempt, null, e);
}
if (response == null) {
if (grpcRequest.shouldRetryExceptionally(lastException)) {
continue;
} else {
throw new RuntimeException(lastException);
}
}
var status = mapResponseStatus(response);
var executionState = getExecutionState(status, response);
grpcRequest.handleResponse(response, status, executionState);
switch (executionState) {
case SERVER_ERROR:
lastException = grpcRequest.mapStatusException();
continue;
case RETRY:
// Response is not ready yet from server, need to wait.
lastException = grpcRequest.mapStatusException();
if (attempt < maxAttempts) {
currentTimeout = Duration.between(Instant.now(), timeoutTime);
delay(Math.min(currentTimeout.toMillis(), grpcRequest.getDelay()));
}
continue;
case REQUEST_ERROR:
throw grpcRequest.mapStatusException();
case SUCCESS:
default:
return grpcRequest.mapResponse();
}
}
}
/**
* Execute this transaction or query asynchronously.
*
* Note: This method requires API level 33 or higher. It will not work on devices running API versions below 31
* because it uses features introduced in API level 31 (Android 12).
*
*
* @param client The client with which this will be executed.
* @return Future result of execution
*/
public CompletableFuture executeAsync(Client client) {
return executeAsync(client, client.getRequestTimeout());
}
/**
* Execute this transaction or query asynchronously.
*
* Note: This method requires API level 33 or higher. It will not work on devices running API versions below 31
* because it uses features introduced in API level 31 (Android 12).
*
*
* @param client The client with which this will be executed.
* @param timeout The timeout after which the execution attempt will be cancelled.
* @return Future result of execution
*/
public CompletableFuture executeAsync(Client client, Duration timeout) {
var retval = new CompletableFuture().orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS);
mergeFromClient(client);
onExecuteAsync(client).thenRun(() -> {
checkNodeAccountIds();
setNodesFromNodeAccountIds(client);
executeAsyncInternal(client, 1, null, retval, timeout);
}).exceptionally(error -> {
retval.completeExceptionally(error);
return null;
});
return retval;
}
/**
* Execute this transaction or query asynchronously.
*
* @param client The client with which this will be executed.
* @param callback a BiConsumer which handles the result or error.
*/
public void executeAsync(Client client, BiConsumer callback) {
ConsumerHelper.biConsumer(executeAsync(client), callback);
}
/**
* Execute this transaction or query asynchronously.
*
* @param client The client with which this will be executed.
* @param timeout The timeout after which the execution attempt will be cancelled.
* @param callback a BiConsumer which handles the result or error.
*/
public void executeAsync(Client client, Duration timeout, BiConsumer callback) {
ConsumerHelper.biConsumer(executeAsync(client, timeout), callback);
}
/**
* Execute this transaction or query asynchronously.
*
* @param client The client with which this will be executed.
* @param onSuccess a Consumer which consumes the result on success.
* @param onFailure a Consumer which consumes the error on failure.
*/
public void executeAsync(Client client, Consumer onSuccess, Consumer onFailure) {
ConsumerHelper.twoConsumers(executeAsync(client), onSuccess, onFailure);
}
/**
* Execute this transaction or query asynchronously.
*
* @param client The client with which this will be executed.
* @param timeout The timeout after which the execution attempt will be cancelled.
* @param onSuccess a Consumer which consumes the result on success.
* @param onFailure a Consumer which consumes the error on failure.
*/
public void executeAsync(Client client, Duration timeout, Consumer onSuccess, Consumer onFailure) {
ConsumerHelper.twoConsumers(executeAsync(client, timeout), onSuccess, onFailure);
}
/**
* Logs the transaction's parameters
*
* @param transactionId the transaction's id
* @param client the client that executed the transaction
* @param node the node the transaction was sent to
* @param isAsync whether the transaction was executed asynchronously
* @param attempt the attempt number
* @param response the transaction response if the transaction was successful
* @param error the error if the transaction was not successful
*/
protected void logTransaction(
TransactionId transactionId,
Client client,
Node node,
boolean isAsync,
int attempt,
@Nullable ResponseT response,
@Nullable Throwable error) {
if (!logger.isEnabledForLevel(LogLevel.TRACE)) {
return;
}
logger.trace("Execute{} Transaction ID: {}, submit to {}, node: {}, attempt: {}",
isAsync ? "Async" : "",
transactionId,
client.network,
node.getAccountId(),
attempt);
if (response != null) {
logger.trace(" - Response: {}", response);
}
if (error != null) {
logger.trace(" - Error: {}", error.getMessage());
}
}
@SuppressWarnings("java:S2245")
@VisibleForTesting
void setNodesFromNodeAccountIds(Client client) {
nodes.clear();
// When a single node is explicitly set we get all of its proxies so in case of
// failure the system can retry with different proxy on each attempt
if (nodeAccountIds.size() == 1) {
var nodeProxies = client.network.getNodeProxies(nodeAccountIds.get(0));
if (nodeProxies == null || nodeProxies.size() == 0) {
throw new IllegalStateException("Account ID did not map to valid node in the client's network");
}
nodes.addAll(nodeProxies).shuffle();
return;
}
// When multiple nodes are available the system retries with different node on each attempt
// instead of different proxy of the same node
for (var accountId : nodeAccountIds) {
@Nullable
var nodeProxies = client.network.getNodeProxies(accountId);
if (nodeProxies == null || nodeProxies.size() == 0) {
throw new IllegalStateException(
"Some node account IDs did not map to valid nodes in the client's network");
}
var node = nodeProxies.get(random.nextInt(nodeProxies.size()));
nodes.add(Objects.requireNonNull(node));
}
}
/**
* Return the next node for execution. Will select the first node that is deemed healthy. If we cannot find such a
* node and have tried n nodes (n being the size of the node list), we will select the node with the smallest
* remaining delay. All delays MUST be executed in calling layer as this method will be called for sync + async
* scenarios.
*/
@VisibleForTesting
Node getNodeForExecute(int attempt) {
Node node = null;
Node candidate = null;
long smallestDelay = Long.MAX_VALUE;
for (int _i = 0; _i < nodes.size(); _i++) {
// NOTE: _i is NOT the index into this.nodes, it is just keeping track of how many times we've iterated.
// In the event of ServerErrors, this method depends on the nodes list to have advanced to
// the next node.
node = nodes.getCurrent();
if (!node.isHealthy()) {
// Keep track of the node with the smallest delay seen thus far. If we go through the entire list
// (meaning all nodes are unhealthy) then we will select the node with the smallest delay.
long backoff = node.getRemainingTimeForBackoff();
if (backoff < smallestDelay) {
candidate = node;
smallestDelay = backoff;
}
node = null;
advanceRequest();
} else {
break; // got a good node, use it
}
}
if (node == null) {
node = candidate;
// If we've tried all nodes, index will be +1 too far. Index increment happens outside
// this method so try to be consistent with happy path.
nodeAccountIds.setIndex(Math.max(0, nodeAccountIds.getIndex()));
}
// node won't be null at this point because execute() validates before this method is called.
// Add null check here to work around sonar NPE detection.
if (node != null && logger != null) {
logger.trace("Using node {} for request #{}: {}", node.getAccountId(), attempt, this);
}
return node;
}
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();
return request;
}
private void executeAsyncInternal(
Client client,
int attempt,
@Nullable Throwable lastException,
CompletableFuture returnFuture,
Duration timeout
) {
// If the logger on the request is not set, use the logger in client
// (if set, otherwise do not use logger)
if (this.logger == null && client.getLogger() != null) {
this.logger = client.getLogger();
}
if (returnFuture.isCancelled() || returnFuture.isCompletedExceptionally() || returnFuture.isDone()) {
return;
}
if (attempt > maxAttempts) {
returnFuture.completeExceptionally(
new CompletionException(new MaxAttemptsExceededException(lastException)));
return;
}
var timeoutTime = Instant.now().plus(timeout);
GrpcRequest grpcRequest = new GrpcRequest(client.network, attempt,
Duration.between(Instant.now(), timeoutTime));
Supplier> afterUnhealthyDelay = () -> {
return grpcRequest.getNode().isHealthy() ?
CompletableFuture.completedFuture((Void) null) :
Delayer.delayFor(grpcRequest.getNode().getRemainingTimeForBackoff(), client.executor);
};
afterUnhealthyDelay.get().thenRun(() -> {
grpcRequest.getNode().channelFailedToConnectAsync().thenAccept(connectionFailed -> {
if (connectionFailed) {
var connectionException = grpcRequest.reactToConnectionFailure();
executeAsyncInternal(client, attempt + 1, connectionException, returnFuture,
Duration.between(Instant.now(), timeoutTime));
return;
}
toCompletableFuture(
ClientCalls.futureUnaryCall(grpcRequest.createCall(), grpcRequest.getRequest())).handle(
(response, error) -> {
logTransaction(this.getTransactionIdInternal(), client, grpcRequest.getNode(), true, attempt,
response, error);
if (grpcRequest.shouldRetryExceptionally(error)) {
// the transaction had a network failure reaching Hedera
executeAsyncInternal(client, attempt + 1, error, returnFuture,
Duration.between(Instant.now(), timeoutTime));
return null;
}
if (error != null) {
// not a network failure, some other weirdness going on; just fail fast
returnFuture.completeExceptionally(new CompletionException(error));
return null;
}
var status = mapResponseStatus(response);
var executionState = getExecutionState(status, response);
grpcRequest.handleResponse(response, status, executionState);
switch (executionState) {
case SERVER_ERROR:
executeAsyncInternal(client, attempt + 1, grpcRequest.mapStatusException(),
returnFuture, Duration.between(Instant.now(), timeoutTime));
break;
case RETRY:
Delayer.delayFor((attempt < maxAttempts) ? grpcRequest.getDelay() : 0, client.executor)
.thenRun(() -> executeAsyncInternal(client, attempt + 1,
grpcRequest.mapStatusException(),
returnFuture, Duration.between(Instant.now(), timeoutTime)));
break;
case REQUEST_ERROR:
returnFuture.completeExceptionally(
new CompletionException(grpcRequest.mapStatusException()));
break;
case SUCCESS:
default:
returnFuture.complete(grpcRequest.mapResponse());
}
return null;
}).exceptionally(error -> {
returnFuture.completeExceptionally(error);
return null;
});
}).exceptionally(error -> {
returnFuture.completeExceptionally(error);
return null;
});
});
}
abstract ProtoRequestT makeRequest();
GrpcRequest getGrpcRequest(int attempt) {
return new GrpcRequest(null, attempt, this.grpcDeadline);
}
void advanceRequest() {
if (nodeAccountIds.getIndex() + 1 == nodes.size() - 1) {
attemptedAllNodes = true;
}
nodes.advance();
if (nodeAccountIds.size() > 1) {
nodeAccountIds.advance();
}
}
/**
* Called after receiving the query response from Hedera. The derived class should map into its output type.
*/
abstract O mapResponse(ResponseT response, AccountId nodeId, ProtoRequestT request);
abstract Status mapResponseStatus(ResponseT response);
/**
* Called to direct the invocation of the query to the appropriate gRPC service.
*/
abstract MethodDescriptor getMethodDescriptor();
@Nullable
abstract TransactionId getTransactionIdInternal();
boolean shouldRetryExceptionally(@Nullable Throwable error) {
if (error instanceof StatusRuntimeException statusException) {
var status = statusException.getStatus().getCode();
var description = statusException.getStatus().getDescription();
return (status == Code.UNAVAILABLE) ||
(status == Code.RESOURCE_EXHAUSTED) ||
(status == Code.INTERNAL && description != null && RST_STREAM.matcher(description).matches());
}
return false;
}
/**
* Default implementation, may be overridden in subclasses (especially for query case). Called just after receiving
* the query response from Hedera. By default it triggers a retry when the pre-check status is {@code BUSY}.
*/
ExecutionState getExecutionState(Status status, ResponseT response) {
switch (status) {
case PLATFORM_TRANSACTION_NOT_CREATED:
case PLATFORM_NOT_ACTIVE:
return ExecutionState.SERVER_ERROR;
case BUSY:
return ExecutionState.RETRY;
case OK:
return ExecutionState.SUCCESS;
default:
return ExecutionState.REQUEST_ERROR; // user error
}
}
@VisibleForTesting
class GrpcRequest {
@Nullable
private final Network network;
private final Node node;
private final int attempt;
//private final ClientCall call;
private final ProtoRequestT request;
private final long startAt;
private final long delay;
private Duration grpcDeadline;
private ResponseT response;
private double latency;
private Status responseStatus;
GrpcRequest(@Nullable Network network, int attempt, Duration grpcDeadline) {
this.network = network;
this.attempt = attempt;
this.grpcDeadline = grpcDeadline;
this.node = getNodeForExecute(attempt);
this.request = getRequestForExecute(); // node index gets incremented here
this.startAt = System.nanoTime();
// Exponential back-off for Delayer: 250ms, 500ms, 1s, 2s, 4s, 8s, ... 8s
delay = (long) Math.min(Objects.requireNonNull(minBackoff).toMillis() * Math.pow(2, attempt - 1.0),
Objects.requireNonNull(maxBackoff).toMillis());
}
public CallOptions getCallOptions() {
long deadline = Math.min(
this.grpcDeadline.toMillis(),
Executable.this.grpcDeadline.toMillis());
return CallOptions.DEFAULT.withDeadlineAfter(deadline, TimeUnit.MILLISECONDS);
}
public void setGrpcDeadline(Duration grpcDeadline) {
this.grpcDeadline = grpcDeadline;
}
public Node getNode() {
return node;
}
public ClientCall createCall() {
verboseLog(node);
return this.node.getChannel().newCall(Executable.this.getMethodDescriptor(), getCallOptions());
}
public ProtoRequestT getRequest() {
return Executable.this.requestListener.apply(request);
}
public long getDelay() {
return delay;
}
Throwable reactToConnectionFailure() {
Objects.requireNonNull(network).increaseBackoff(node);
logger.warn("Retrying in {} ms after channel connection failure with node {} during attempt #{}",
node.getRemainingTimeForBackoff(), node.getAccountId(), attempt);
verboseLog(node);
return new IllegalStateException("Failed to connect to node " + node.getAccountId());
}
boolean shouldRetryExceptionally(@Nullable Throwable e) {
latency = (double) (System.nanoTime() - startAt) / 1000000000.0;
var retry = Executable.this.shouldRetryExceptionally(e);
if (retry) {
Objects.requireNonNull(network).increaseBackoff(node);
logger.warn("Retrying in {} ms after failure with node {} during attempt #{}: {}",
node.getRemainingTimeForBackoff(), node.getAccountId(), attempt,
e != null ? e.getMessage() : "NULL");
verboseLog(node);
}
return retry;
}
PrecheckStatusException mapStatusException() {
// request to hedera failed in a non-recoverable way
return new PrecheckStatusException(responseStatus, Executable.this.getTransactionIdInternal());
}
O mapResponse() {
// successful response from Hedera
return Executable.this.mapResponse(response, node.getAccountId(), request);
}
void handleResponse(ResponseT response, Status status, ExecutionState executionState) {
node.decreaseBackoff();
this.response = Executable.this.responseListener.apply(response);
this.responseStatus = status;
logger.trace("Received {} response in {} s from node {} during attempt #{}: {}",
responseStatus, latency, node.getAccountId(), attempt, response);
if (executionState == ExecutionState.SERVER_ERROR && attemptedAllNodes) {
executionState = ExecutionState.RETRY;
attemptedAllNodes = false;
}
switch (executionState) {
case RETRY -> {
logger.warn("Retrying in {} ms after failure with node {} during attempt #{}: {}",
delay, node.getAccountId(), attempt, responseStatus);
verboseLog(node);
}
case SERVER_ERROR ->
logger.warn("Problem submitting request to node {} for attempt #{}, retry with new node: {}",
node.getAccountId(), attempt, responseStatus);
default -> {
}
}
}
void verboseLog(Node node) {
String ipAddress;
if (node.address == null) {
ipAddress = "NULL";
} else if (node.address.getAddress() == null) {
ipAddress = "NULL";
} else {
ipAddress = node.address.getAddress();
}
logger.trace("Node IP {} Timestamp {} Transaction Type {}",
ipAddress,
System.currentTimeMillis(),
this.getClass().getSimpleName()
);
}
}
}