Skip to content

Commit 4beb2eb

Browse files
committed
feat(gax): retry chunk upload on transient errors
Wraps chunk uploads in a retrying executor to retry transient network and server errors using exponential backoff. Retries individual chunks without restarting the entire upload session.
1 parent 01efb2f commit 4beb2eb

5 files changed

Lines changed: 162 additions & 17 deletions

File tree

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,16 @@
3535
import com.google.api.core.ApiFutures;
3636
import com.google.api.core.BetaApi;
3737
import com.google.api.core.InternalApi;
38+
import com.google.api.gax.resumable.ChunkUploadRequest;
39+
import com.google.api.gax.resumable.ChunkUploadResponse;
3840
import com.google.api.gax.resumable.ResumableUploadClient;
3941
import com.google.api.gax.resumable.ResumableUploadSession;
42+
import com.google.api.gax.retrying.ExponentialRetryAlgorithm;
43+
import com.google.api.gax.retrying.RetryAlgorithm;
44+
import com.google.api.gax.retrying.RetrySettings;
45+
import com.google.api.gax.retrying.ScheduledRetryingExecutor;
4046
import java.io.InputStream;
47+
import java.time.Duration;
4148
import org.jspecify.annotations.NullMarked;
4249
import org.jspecify.annotations.Nullable;
4350

@@ -54,9 +61,19 @@
5461
public class ResumableUploadCallableImpl<RequestT, ResponseT>
5562
extends ResumableUploadCallable<RequestT, ResponseT> {
5663

64+
private static final RetrySettings RETRY_SETTINGS =
65+
RetrySettings.newBuilder()
66+
.setInitialRetryDelayDuration(Duration.ofMillis(100))
67+
.setRetryDelayMultiplier(1.3)
68+
.setMaxRetryDelayDuration(Duration.ofMinutes(1))
69+
.setMaxAttempts(5)
70+
.build();
71+
5772
private final ResumableUploadClient<RequestT, ResponseT> client;
5873
private final ResumableUploadCallSettings defaultCallSettings;
5974
private final ClientContext clientContext;
75+
private final UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
76+
retryingUploadChunkCallable;
6077

6178
public ResumableUploadCallableImpl(
6279
ResumableUploadClient<RequestT, ResponseT> client,
@@ -65,7 +82,13 @@ public ResumableUploadCallableImpl(
6582
this.client = checkNotNull(client, "client must not be null");
6683
this.defaultCallSettings =
6784
checkNotNull(defaultCallSettings, "defaultCallSettings must not be null");
68-
this.clientContext = checkNotNull(clientContext, "clientContext must not be null");
85+
this.clientContext =
86+
checkNotNull(clientContext, "clientContext must not be null").toBuilder()
87+
.setDefaultCallContext(
88+
clientContext.getDefaultCallContext().withRetrySettings(RETRY_SETTINGS))
89+
.build();
90+
this.retryingUploadChunkCallable =
91+
createRetryingCallable(client.uploadChunkCallable(), ResumableUploadCommand.UPLOAD);
6992
}
7093

7194
@Override
@@ -87,16 +110,24 @@ public ResumableUploadFuture<ResponseT> futureCall(
87110
}
88111

89112
return ResumableUploadFutureImpl.create(
90-
startFuture,
91-
client.uploadChunkCallable(),
92-
payload,
93-
effectiveSettings,
94-
clientContext.getDefaultCallContext());
113+
startFuture, retryingUploadChunkCallable, payload, effectiveSettings, clientContext);
95114
}
96115

97116
@Override
98117
public ResumableUploadFuture<ResponseT> resumeCall(
99118
String sessionUrl, InputStream payload, @Nullable ResumableUploadCallSettings settings) {
100119
throw new UnsupportedOperationException("Session resumption is not yet implemented.");
101120
}
121+
122+
private <ReqT, RespT> UnaryCallable<ReqT, RespT> createRetryingCallable(
123+
UnaryCallable<ReqT, RespT> callable, ResumableUploadCommand command) {
124+
RetryAlgorithm<RespT> retryAlgorithm =
125+
new RetryAlgorithm<>(
126+
new ResumableUploadResultRetryAlgorithm<>(command),
127+
new ExponentialRetryAlgorithm(RETRY_SETTINGS, clientContext.getClock()));
128+
return new RetryingCallable<>(
129+
clientContext.getDefaultCallContext(),
130+
checkNotNull(callable, "callable must not be null"),
131+
new ScheduledRetryingExecutor<>(retryAlgorithm, clientContext.getExecutor()));
132+
}
102133
}

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadChunkCoordinator.java

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import java.io.InputStream;
4646
import java.util.Arrays;
4747
import java.util.concurrent.CancellationException;
48+
import java.util.concurrent.Executor;
4849
import org.jspecify.annotations.NullMarked;
4950
import org.jspecify.annotations.Nullable;
5051

@@ -59,6 +60,9 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
5960

6061
private static final byte[] EMPTY_PAYLOAD = new byte[0];
6162

63+
private final Executor chunkExecutor =
64+
MoreExecutors.newSequentialExecutor(MoreExecutors.directExecutor());
65+
6266
private final UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
6367
uploadChunkCallable;
6468
private final String uploadUrl;
@@ -74,13 +78,14 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
7478
String uploadUrl,
7579
InputStream payload,
7680
int chunkSize,
77-
ApiCallContext callContext) {
81+
ClientContext clientContext) {
7882
this.uploadChunkCallable =
7983
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
8084
this.uploadUrl = checkNotNull(uploadUrl, "uploadUrl must not be null");
8185
this.payload = checkNotNull(payload, "payload must not be null");
8286
this.chunkSize = chunkSize;
83-
this.callContext = checkNotNull(callContext, "callContext must not be null");
87+
checkNotNull(clientContext, "clientContext must not be null");
88+
this.callContext = clientContext.getDefaultCallContext();
8489
this.buffer = new byte[chunkSize];
8590
}
8691

@@ -93,7 +98,7 @@ ApiFuture<ResponseT> start() {
9398
}
9499
},
95100
MoreExecutors.directExecutor());
96-
transmitChunk(0L);
101+
chunkExecutor.execute(() -> transmitChunk(0L));
97102
return result;
98103
}
99104

@@ -157,9 +162,10 @@ public void onSuccess(ChunkUploadResponse<ResponseT> response) {
157162
result.setException(
158163
new IllegalStateException(
159164
"Upload stream ended and final chunk was transmitted, but server returned"
160-
+ " incomplete status"));
165+
+ " incomplete status for upload URL: "
166+
+ uploadUrl));
161167
} else {
162-
transmitChunk(nextOffset);
168+
chunkExecutor.execute(() -> transmitChunk(nextOffset));
163169
}
164170
}
165171

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFutureImpl.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ final class ResumableUploadFutureImpl<ResponseT> implements ResumableUploadFutur
6767
uploadChunkCallable;
6868
private final InputStream payload;
6969
private final ResumableUploadCallSettings settings;
70-
private final ApiCallContext callContext;
70+
private final ClientContext clientContext;
7171
private final SettableApiFuture<ResponseT> resultFuture = SettableApiFuture.create();
7272

7373
private volatile @Nullable String uploadSessionUrl;
@@ -87,10 +87,10 @@ static <ResponseT> ResumableUploadFutureImpl<ResponseT> create(
8787
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
8888
InputStream payload,
8989
ResumableUploadCallSettings settings,
90-
ApiCallContext callContext) {
90+
ClientContext clientContext) {
9191
ResumableUploadFutureImpl<ResponseT> future =
9292
new ResumableUploadFutureImpl<>(
93-
startFuture, uploadChunkCallable, payload, settings, callContext);
93+
startFuture, uploadChunkCallable, payload, settings, clientContext);
9494
try {
9595
future.start();
9696
} catch (Throwable t) {
@@ -104,14 +104,14 @@ private ResumableUploadFutureImpl(
104104
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
105105
InputStream payload,
106106
ResumableUploadCallSettings settings,
107-
ApiCallContext callContext) {
107+
ClientContext clientContext) {
108108
this.startFuture = checkNotNull(startFuture, "startFuture must not be null");
109109
this.uploadChunkCallable =
110110
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
111111
this.payload = checkNotNull(payload, "payload must not be null");
112112
this.settings = checkNotNull(settings, "settings must not be null");
113113
checkArgument(settings.getChunkSize() > 0, "chunkSize must be > 0");
114-
this.callContext = checkNotNull(callContext, "callContext must not be null");
114+
this.clientContext = checkNotNull(clientContext, "clientContext must not be null");
115115
this.inFlightFuture = startFuture;
116116
}
117117

@@ -131,7 +131,7 @@ public void onSuccess(ResumableUploadSession session) {
131131
uploadSessionUrl,
132132
payload,
133133
settings.getChunkSize(),
134-
callContext);
134+
clientContext);
135135
ApiFuture<ResponseT> uploadFuture;
136136
try {
137137
uploadFuture = coordinator.start();

sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/CallableTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ void testWatched_usesJavaTimeMethods() {
214214
void testResumableUploadCallable() {
215215
ResumableUploadClient<String, String> uploadClient =
216216
mock(ResumableUploadClient.class, Mockito.withSettings().withoutAnnotations());
217+
when(uploadClient.uploadChunkCallable())
218+
.thenReturn(mock(UnaryCallable.class, Mockito.withSettings().withoutAnnotations()));
217219
ResumableUploadCallSettings settings =
218220
ResumableUploadCallSettings.newBuilder().setChunkSize(1024).build();
219221

sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallableImplTest.java

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,102 @@ void testResumeCall_throwsUnsupportedOperationException() {
404404
() -> callable.resumeCall("https://upload.url/session", streamOf("data"), null));
405405
}
406406

407+
@Test
408+
void testChunkRetry_transientFailureThenSuccess_retriesAndSucceeds() throws Exception {
409+
stubStartSession("https://upload.url/chunk-retry-ok");
410+
TrackableStream stream = new TrackableStream("01234567"); // exactly 1 chunk of 8 bytes
411+
when(mockChunkCallable.futureCall(any(ChunkUploadRequest.class), any()))
412+
.thenReturn(
413+
ApiFutures.immediateFailedFuture(createApiException(503, StatusCode.Code.UNAVAILABLE)))
414+
.thenReturn(
415+
ApiFutures.immediateFuture(
416+
ChunkUploadResponse.create(ResumableUploadStatus.FINAL, "chunk-done")));
417+
418+
ResumableUploadFuture<String> future = callable.futureCall("resource-path", stream, null);
419+
420+
assertThat(future.get()).isEqualTo("chunk-done");
421+
assertThat(future.isDone()).isTrue();
422+
assertThat(stream.totalBytesRead).isEqualTo(8);
423+
assertThat(stream.closed).isTrue();
424+
425+
ArgumentCaptor<ChunkUploadRequest> captor = ArgumentCaptor.forClass(ChunkUploadRequest.class);
426+
verify(mockChunkCallable, times(2)).futureCall(captor.capture(), any());
427+
List<ChunkUploadRequest> requests = captor.getAllValues();
428+
assertThat(requests.get(0).getOffset()).isEqualTo(0);
429+
assertThat(requests.get(0).getPayload()).isEqualTo("01234567".getBytes(StandardCharsets.UTF_8));
430+
assertThat(requests.get(1).getOffset()).isEqualTo(0);
431+
assertThat(requests.get(1).getPayload()).isEqualTo("01234567".getBytes(StandardCharsets.UTF_8));
432+
}
433+
434+
@Test
435+
void testChunkRetry_transientFailureExhaustion_surfacesLastError() {
436+
stubStartSession("https://upload.url/chunk-exhaustion");
437+
when(mockChunkCallable.futureCall(any(ChunkUploadRequest.class), any()))
438+
.thenReturn(
439+
ApiFutures.immediateFailedFuture(createApiException(503, StatusCode.Code.UNAVAILABLE)));
440+
441+
ResumableUploadFuture<String> future =
442+
callable.futureCall("resource-path", streamOf("hello"), null);
443+
444+
ExecutionException exception = assertThrows(ExecutionException.class, future::get);
445+
assertThat(exception.getCause()).isInstanceOf(ApiException.class);
446+
assertThat(((ApiException) exception.getCause()).getStatusCode().getTransportCode())
447+
.isEqualTo(503);
448+
449+
// Default chunk retry settings has maxAttempts = 5
450+
verify(mockChunkCallable, times(5)).futureCall(any(), any());
451+
}
452+
453+
@Test
454+
void testChunkRetry_cancellationDuringBackoff_deschedulesPendingAttempt() {
455+
stubStartSession("https://upload.url/cancel-backoff");
456+
SettableApiFuture<ChunkUploadResponse<String>> chunkAttempt0Future = SettableApiFuture.create();
457+
when(mockChunkCallable.futureCall(any(ChunkUploadRequest.class), any()))
458+
.thenReturn(chunkAttempt0Future)
459+
.thenReturn(
460+
ApiFutures.immediateFuture(
461+
ChunkUploadResponse.create(ResumableUploadStatus.FINAL, "should-not-reach")));
462+
463+
ResumableUploadFuture<String> sessionFuture =
464+
callable.futureCall("resource-path", streamOf("hello"), null);
465+
466+
// Fail attempt 0 with 503 to schedule backoff
467+
chunkAttempt0Future.setException(createApiException(503, StatusCode.Code.UNAVAILABLE));
468+
469+
// Cancel while backoff is pending
470+
assertThat(sessionFuture.cancel(true)).isTrue();
471+
assertThat(sessionFuture.isCancelled()).isTrue();
472+
assertThrows(CancellationException.class, sessionFuture::get);
473+
474+
// Only attempt 0 occurred; attempt 1 was de-scheduled
475+
verify(mockChunkCallable, times(1)).futureCall(any(), any());
476+
}
477+
478+
private static class HttpStatusStatusCode implements StatusCode {
479+
private final int httpStatus;
480+
private final StatusCode.Code code;
481+
482+
HttpStatusStatusCode(int httpStatus, StatusCode.Code code) {
483+
this.httpStatus = httpStatus;
484+
this.code = code;
485+
}
486+
487+
@Override
488+
public StatusCode.Code getCode() {
489+
return code;
490+
}
491+
492+
@Override
493+
public Integer getTransportCode() {
494+
return httpStatus;
495+
}
496+
}
497+
498+
private static ApiException createApiException(int httpStatus, StatusCode.Code code) {
499+
return ApiExceptionFactory.createException(
500+
"HTTP " + httpStatus, null, new HttpStatusStatusCode(httpStatus, code), false);
501+
}
502+
407503
private void stubStartSession(String uploadUrl) {
408504
when(mockStartCallable.futureCall(any(), any()))
409505
.thenReturn(
@@ -424,11 +520,21 @@ private static void assertChunk(
424520

425521
private static class TrackableStream extends ByteArrayInputStream {
426522
boolean closed = false;
523+
int totalBytesRead = 0;
427524

428525
TrackableStream(String content) {
429526
super(content.getBytes(StandardCharsets.UTF_8));
430527
}
431528

529+
@Override
530+
public int read(byte[] b, int off, int len) {
531+
int read = super.read(b, off, len);
532+
if (read > 0) {
533+
totalBytesRead += read;
534+
}
535+
return read;
536+
}
537+
432538
@Override
433539
public void close() throws IOException {
434540
closed = true;

0 commit comments

Comments
 (0)