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
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,8 @@
import com.google.common.base.Stopwatch;
import com.google.protobuf.Duration;
import com.google.protobuf.util.Durations;
import com.google.rpc.RetryInfo;
import io.grpc.Context;
import io.grpc.Status;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import java.util.logging.Level;
Expand All @@ -37,6 +35,9 @@ public class RetryingVRpc<ReqT, RespT> implements VRpc<ReqT, RespT> {

private static final Logger LOG = Logger.getLogger(RetryingVRpc.class.getName());

static final long INITIAL_TRANSPORT_FAILURE_BACKOFF_MS = 10L;
static final long MAX_TRANSPORT_FAILURE_BACKOFF_MS = 1000L;

private final Context grpcContext;
private final io.opentelemetry.context.Context otelContext;

Expand Down Expand Up @@ -239,12 +240,9 @@ public void onClose(VRpcResult result) {
return;
}
finishAttempt(result);
if (shouldRetry(result)) {
Duration retryDelay = getRetryDelay(result);
if (shouldRetry(result, retryDelay)) {
context = context.createForNextAttempt();
Duration retryDelay =
Optional.ofNullable(result.getRetryInfo())
.map(RetryInfo::getRetryDelay)
.orElse(Durations.ZERO);
if (Durations.compare(retryDelay, Durations.ZERO) > 0) {
Scheduled scheduled = new Scheduled(retryDelay);
onStateChange(scheduled);
Expand Down Expand Up @@ -284,17 +282,34 @@ public void onExit() {
Status.CANCELLED.withDescription("attempt abandoned during transition")));
}

boolean shouldRetry(VRpcResult result) {
Duration getRetryDelay(VRpcResult result) {
if (result.getRetryInfo() != null && result.getRetryInfo().hasRetryDelay()) {
return result.getRetryInfo().getRetryDelay();
}
if (result.getState() == VRpcResult.State.TRANSPORT_FAILURE) {
int attemptNumber = context.getOperationInfo().getAttemptNumber();
long delayMs =
Math.min(
MAX_TRANSPORT_FAILURE_BACKOFF_MS,
INITIAL_TRANSPORT_FAILURE_BACKOFF_MS * (1L << Math.min(attemptNumber, 16)));
return Durations.fromMillis(delayMs);
}
return Durations.ZERO;
}

boolean shouldRetry(VRpcResult result, Duration retryDelay) {
long retryDelayMs = Durations.toMillis(retryDelay);
// only schedule retry if there's still a chance for the request to succeed
boolean hasRemainingTime =
context.getOperationInfo().getDeadline().timeRemaining(TimeUnit.MILLISECONDS)
- retryDelayMs
> 1;
Comment on lines +303 to +306

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the operation does not have a deadline configured, context.getOperationInfo().getDeadline() may return null. Calling timeRemaining on a null deadline will throw a NullPointerException. We should defensively check if the deadline is null and default hasRemainingTime to true in that case.

Suggested change
boolean hasRemainingTime =
context.getOperationInfo().getDeadline().timeRemaining(TimeUnit.MILLISECONDS)
- retryDelayMs
> 1;
io.grpc.Deadline deadline = context.getOperationInfo().getDeadline();
boolean hasRemainingTime =
deadline == null
|| deadline.timeRemaining(TimeUnit.MILLISECONDS) - retryDelayMs > 1;

// If the error has RetryInfo, it means it comes from the server and should
// be retried.
if (!result.getStatus().isOk()
&& result.getRetryInfo() != null
&& result.getRetryInfo().hasRetryDelay()) {
long retryDelay = Durations.toMillis(result.getRetryInfo().getRetryDelay());
// only schedule retry if there's still a chance for the request to succeed
return context.getOperationInfo().getDeadline().timeRemaining(TimeUnit.MILLISECONDS)
- retryDelay
> 1;
return hasRemainingTime;
}
// Do not retry result that is explicitly rejected
if (result.getRejected()) {
Expand All @@ -307,7 +322,7 @@ boolean shouldRetry(VRpcResult result) {
|| (context.getOperationInfo().isIdempotent()
&& result.getState() == VRpcResult.State.TRANSPORT_FAILURE);
if (isRetryable && context.getOperationInfo().getAttemptNumber() < 3) {
return true;
return hasRemainingTime;
}
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,16 @@
import io.grpc.Server;
import io.grpc.Status;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import javax.annotation.Nullable;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -414,4 +420,124 @@ void cancelInScheduledState() throws Exception {
VRpcException vRpcException = (VRpcException) executionException.getCause();
assertThat(vRpcException.getStatus().getCode()).isEqualTo(Status.Code.CANCELLED);
}

@Test
void retryTransportFailureWithExponentialBackoff() throws Exception {
List<Long> scheduledDelaysMs = Collections.synchronizedList(new ArrayList<>());
BigtableTimer recordingTimer =
new BigtableTimer() {
@Override
public Timeout newTimeout(Runnable task, Executor exec, long delay, TimeUnit unit) {
scheduledDelaysMs.add(unit.toMillis(delay));
return timer.newTimeout(task, exec, delay, unit);
}

@Override
public void stop() {
timer.stop();
}

@Override
public Registration onStop(Runnable hook) {
return timer.onStop(hook);
}
};

AtomicInteger attemptCount = new AtomicInteger();
RetryingVRpc<SessionFakeScriptedRequest, SessionFakeScriptedResponse> retrying =
new RetryingVRpc<>(
() ->
new VRpc<SessionFakeScriptedRequest, SessionFakeScriptedResponse>() {
@Override
public void start(
SessionFakeScriptedRequest req,
VRpc.VRpcCallContext ctx,
VRpc.VRpcListener<SessionFakeScriptedResponse> listener) {
int attempt = attemptCount.getAndIncrement();
ctx.getExecutor()
.execute(
() -> {
if (attempt < 3) {
listener.onClose(
VRpc.VRpcResult.createLocalTransportError(
Status.UNAVAILABLE.withDescription("missed heartbeat")));
} else {
listener.onMessage(
SessionFakeScriptedResponse.getDefaultInstance());
listener.onClose(
VRpc.VRpcResult.createServerOk(
VirtualRpcResponse.getDefaultInstance()));
}
});
}

@Override
public void cancel(@Nullable String message, @Nullable Throwable cause) {}

@Override
public boolean isDone() {
return true;
}

@Override
public void requestNext() {}
},
recordingTimer);

UnaryResponseFuture<SessionFakeScriptedResponse> f = new UnaryResponseFuture<>();
retrying.start(
SessionFakeScriptedRequest.getDefaultInstance(),
VRpc.VRpcCallContext.create(Deadline.after(1, TimeUnit.MINUTES), true, tracer),
f);

assertThat(f.get()).isEqualTo(SessionFakeScriptedResponse.getDefaultInstance());
assertThat(attemptCount.get()).isEqualTo(4);
assertThat(scheduledDelaysMs).containsExactly(10L, 20L, 40L).inOrder();
}

@Test
void retryTransportFailureDeadlineExceeded() {
AtomicInteger attemptCount = new AtomicInteger();
RetryingVRpc<SessionFakeScriptedRequest, SessionFakeScriptedResponse> retrying =
new RetryingVRpc<>(
() ->
new VRpc<SessionFakeScriptedRequest, SessionFakeScriptedResponse>() {
@Override
public void start(
SessionFakeScriptedRequest req,
VRpc.VRpcCallContext ctx,
VRpc.VRpcListener<SessionFakeScriptedResponse> listener) {
attemptCount.incrementAndGet();
ctx.getExecutor()
.execute(
() ->
listener.onClose(
VRpc.VRpcResult.createLocalTransportError(
Status.UNAVAILABLE.withDescription("missed heartbeat"))));
}

@Override
public void cancel(@Nullable String message, @Nullable Throwable cause) {}

@Override
public boolean isDone() {
return true;
}

@Override
public void requestNext() {}
},
timer);

UnaryResponseFuture<SessionFakeScriptedResponse> f = new UnaryResponseFuture<>();
// Initial transport failure retry delay is 10ms, which exceeds a 5ms deadline.
retrying.start(
SessionFakeScriptedRequest.getDefaultInstance(),
VRpc.VRpcCallContext.create(Deadline.after(5, TimeUnit.MILLISECONDS), true, tracer),
f);

ExecutionException exception = assertThrows(ExecutionException.class, f::get);
assertThat(exception).hasMessageThat().contains("missed heartbeat");
assertThat(attemptCount.get()).isEqualTo(1);
}
}
Loading