-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(gax): add ResumableUploadResultRetryAlgorithm #14225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: whowes/rewindable-stream-buffer
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| /* | ||
| * Copyright 2026 Google LLC | ||
| * | ||
| * Redistribution and use in source and binary forms, with or without | ||
| * modification, are permitted provided that the following conditions are | ||
| * met: | ||
| * | ||
| * * Redistributions of source code must retain the above copyright | ||
| * notice, this list of conditions and the following disclaimer. | ||
| * * Redistributions in binary form must reproduce the above | ||
| * copyright notice, this list of conditions and the following disclaimer | ||
| * in the documentation and/or other materials provided with the | ||
| * distribution. | ||
| * * Neither the name of Google LLC nor the names of its | ||
| * contributors may be used to endorse or promote products derived from | ||
| * this software without specific prior written permission. | ||
| * | ||
| * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | ||
| * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | ||
| * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | ||
| * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | ||
| * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | ||
| * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | ||
| * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | ||
| * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | ||
| * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | ||
| * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||
| * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||
| */ | ||
| package com.google.api.gax.resumable; | ||
|
|
||
| import com.google.api.core.BetaApi; | ||
| import com.google.api.core.InternalApi; | ||
| import com.google.api.gax.retrying.BasicResultRetryAlgorithm; | ||
| import com.google.api.gax.retrying.ResultRetryAlgorithm; | ||
| import com.google.api.gax.retrying.RetryingContext; | ||
| import com.google.api.gax.rpc.ApiException; | ||
| import com.google.api.gax.rpc.StatusCode; | ||
| import com.google.common.collect.ImmutableSet; | ||
| import java.io.IOException; | ||
| import java.util.Set; | ||
| import java.util.concurrent.CancellationException; | ||
| import org.jspecify.annotations.NullMarked; | ||
| import org.jspecify.annotations.Nullable; | ||
|
|
||
| /** | ||
| * Implementation of {@link ResultRetryAlgorithm} for resumable uploads based on the Unified | ||
| * Resumable Upload Protocol specification. | ||
| * | ||
| * <p>Differentiates between: | ||
| * | ||
| * <ul> | ||
| * <li><b>Category 1 (Transient)</b>: Retriable without modification (e.g. UNAVAILABLE, | ||
| * DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, network I/O errors). | ||
| * <li><b>Category 2 (Recoverable)</b>: Retriable with modification (e.g. OUT_OF_RANGE, | ||
| * INVALID_ARGUMENT, FAILED_PRECONDITION, ABORTED, INTERNAL) where the upload offset must be | ||
| * recovered via queryStatus. | ||
| * <li><b>Category 3 (Terminal)</b>: Fatal errors (e.g. NOT_FOUND, UNAUTHENTICATED, | ||
| * PERMISSION_DENIED, CancellationException) which abort immediately. | ||
| * </ul> | ||
| */ | ||
| @BetaApi | ||
| @InternalApi | ||
| @NullMarked | ||
| public class ResumableUploadResultRetryAlgorithm<ResponseT> | ||
|
Check warning on line 65 in sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithm.java
|
||
| extends BasicResultRetryAlgorithm<ResponseT> { | ||
|
|
||
| private static final Set<StatusCode.Code> DEFAULT_RETRYABLE_CODES = | ||
| ImmutableSet.of( | ||
| // Category 1: Transient errors | ||
| StatusCode.Code.UNAVAILABLE, | ||
| StatusCode.Code.DEADLINE_EXCEEDED, | ||
| StatusCode.Code.RESOURCE_EXHAUSTED, | ||
| // Category 2: Recoverable errors (offset mismatch, precondition, missing header) | ||
| StatusCode.Code.OUT_OF_RANGE, | ||
| StatusCode.Code.INVALID_ARGUMENT, | ||
| StatusCode.Code.FAILED_PRECONDITION, | ||
| StatusCode.Code.ABORTED, | ||
| StatusCode.Code.INTERNAL); | ||
|
|
||
| private final Set<StatusCode.Code> retryableCodes; | ||
|
|
||
| public static <ResponseT> ResumableUploadResultRetryAlgorithm<ResponseT> create() { | ||
|
Check warning on line 83 in sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithm.java
|
||
| return new ResumableUploadResultRetryAlgorithm<>(DEFAULT_RETRYABLE_CODES); | ||
| } | ||
|
|
||
| public static <ResponseT> ResumableUploadResultRetryAlgorithm<ResponseT> create( | ||
|
Check warning on line 87 in sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithm.java
|
||
| Set<StatusCode.Code> retryableCodes) { | ||
| return new ResumableUploadResultRetryAlgorithm<>(retryableCodes); | ||
| } | ||
|
|
||
| public ResumableUploadResultRetryAlgorithm() { | ||
| this(DEFAULT_RETRYABLE_CODES); | ||
| } | ||
|
|
||
| public ResumableUploadResultRetryAlgorithm(Set<StatusCode.Code> retryableCodes) { | ||
| this.retryableCodes = ImmutableSet.copyOf(retryableCodes); | ||
| } | ||
|
|
||
| public Set<StatusCode.Code> getRetryableCodes() { | ||
| return retryableCodes; | ||
| } | ||
|
|
||
| @Override | ||
| public boolean shouldRetry( | ||
| @Nullable Throwable previousThrowable, @Nullable ResponseT previousResponse) { | ||
| if (previousThrowable == null) { | ||
| return false; | ||
| } | ||
| if (previousThrowable instanceof CancellationException) { | ||
| return false; | ||
| } | ||
| if (previousThrowable instanceof ApiException) { | ||
| StatusCode.Code code = ((ApiException) previousThrowable).getStatusCode().getCode(); | ||
| return retryableCodes.contains(code); | ||
| } | ||
| if (previousThrowable instanceof IOException) { | ||
|
Check warning on line 117 in sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithm.java
|
||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| @Override | ||
| public boolean shouldRetry( | ||
| RetryingContext context, | ||
| @Nullable Throwable previousThrowable, | ||
| @Nullable ResponseT previousResponse) { | ||
| if (context.getRetryableCodes() != null) { | ||
| if (previousThrowable instanceof ApiException) { | ||
|
Check warning on line 129 in sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithm.java
|
||
| return context | ||
| .getRetryableCodes() | ||
| .contains(((ApiException) previousThrowable).getStatusCode().getCode()); | ||
| } | ||
| } | ||
| return shouldRetry(previousThrowable, previousResponse); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,171 @@ | ||||||||||||||||||
| /* | ||||||||||||||||||
| * Copyright 2026 Google LLC | ||||||||||||||||||
| * | ||||||||||||||||||
| * Redistribution and use in source and binary forms, with or without | ||||||||||||||||||
| * modification, are permitted provided that the following conditions are | ||||||||||||||||||
| * met: | ||||||||||||||||||
| * | ||||||||||||||||||
| * * Redistributions of source code must retain the above copyright | ||||||||||||||||||
| * notice, this list of conditions and the following disclaimer. | ||||||||||||||||||
| * * Redistributions in binary form must reproduce the above | ||||||||||||||||||
| * copyright notice, this list of conditions and the following disclaimer | ||||||||||||||||||
| * in the documentation and/or other materials provided with the | ||||||||||||||||||
| * distribution. | ||||||||||||||||||
| * * Neither the name of Google LLC nor the names of its | ||||||||||||||||||
| * contributors may be used to endorse or promote products derived from | ||||||||||||||||||
| * this software without specific prior written permission. | ||||||||||||||||||
| * | ||||||||||||||||||
| * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | ||||||||||||||||||
| * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | ||||||||||||||||||
| * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | ||||||||||||||||||
| * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | ||||||||||||||||||
| * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | ||||||||||||||||||
| * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | ||||||||||||||||||
| * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | ||||||||||||||||||
| * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | ||||||||||||||||||
| * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | ||||||||||||||||||
| * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||||||||||||||||||
| * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||||||||||||||||||
| */ | ||||||||||||||||||
| package com.google.api.gax.resumable; | ||||||||||||||||||
|
|
||||||||||||||||||
| import static com.google.common.truth.Truth.assertThat; | ||||||||||||||||||
|
|
||||||||||||||||||
| import com.google.api.gax.rpc.AbortedException; | ||||||||||||||||||
| import com.google.api.gax.rpc.DeadlineExceededException; | ||||||||||||||||||
| import com.google.api.gax.rpc.FailedPreconditionException; | ||||||||||||||||||
| import com.google.api.gax.rpc.InternalException; | ||||||||||||||||||
| import com.google.api.gax.rpc.InvalidArgumentException; | ||||||||||||||||||
| import com.google.api.gax.rpc.NotFoundException; | ||||||||||||||||||
| import com.google.api.gax.rpc.OutOfRangeException; | ||||||||||||||||||
| import com.google.api.gax.rpc.PermissionDeniedException; | ||||||||||||||||||
| import com.google.api.gax.rpc.ResourceExhaustedException; | ||||||||||||||||||
| import com.google.api.gax.rpc.StatusCode; | ||||||||||||||||||
| import com.google.api.gax.rpc.UnauthenticatedException; | ||||||||||||||||||
| import com.google.api.gax.rpc.UnavailableException; | ||||||||||||||||||
| import com.google.api.gax.rpc.testing.FakeCallContext; | ||||||||||||||||||
| import com.google.api.gax.rpc.testing.FakeStatusCode; | ||||||||||||||||||
| import com.google.common.collect.ImmutableSet; | ||||||||||||||||||
| import java.io.IOException; | ||||||||||||||||||
| import java.util.Collections; | ||||||||||||||||||
| import java.util.concurrent.CancellationException; | ||||||||||||||||||
| import org.junit.jupiter.api.Test; | ||||||||||||||||||
|
|
||||||||||||||||||
| class ResumableUploadResultRetryAlgorithmTest { | ||||||||||||||||||
|
|
||||||||||||||||||
| private final ResumableUploadResultRetryAlgorithm<String> algorithm = | ||||||||||||||||||
| ResumableUploadResultRetryAlgorithm.create(); | ||||||||||||||||||
|
|
||||||||||||||||||
| @Test | ||||||||||||||||||
| void testCategory1TransientErrors_shouldRetry() { | ||||||||||||||||||
| // 503 Unavailable | ||||||||||||||||||
| UnavailableException unavailable = | ||||||||||||||||||
| new UnavailableException( | ||||||||||||||||||
| "unavailable", null, FakeStatusCode.of(StatusCode.Code.UNAVAILABLE), false); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(unavailable, null)).isTrue(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // 504 DeadlineExceeded | ||||||||||||||||||
| DeadlineExceededException deadlineExceeded = | ||||||||||||||||||
| new DeadlineExceededException( | ||||||||||||||||||
| "deadline exceeded", null, FakeStatusCode.of(StatusCode.Code.DEADLINE_EXCEEDED), false); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(deadlineExceeded, null)).isTrue(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // 429 ResourceExhausted | ||||||||||||||||||
| ResourceExhaustedException resourceExhausted = | ||||||||||||||||||
| new ResourceExhaustedException( | ||||||||||||||||||
| "quota exceeded", null, FakeStatusCode.of(StatusCode.Code.RESOURCE_EXHAUSTED), false); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(resourceExhausted, null)).isTrue(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Network / Socket I/O exception | ||||||||||||||||||
| IOException ioException = new IOException("connection reset by peer"); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(ioException, null)).isTrue(); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| @Test | ||||||||||||||||||
| void testCategory2RecoverableErrors_shouldRetry() { | ||||||||||||||||||
| // 416 OutOfRange (chunk offset mismatch) | ||||||||||||||||||
| OutOfRangeException outOfRange = | ||||||||||||||||||
| new OutOfRangeException( | ||||||||||||||||||
| "out of range", null, FakeStatusCode.of(StatusCode.Code.OUT_OF_RANGE), false); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(outOfRange, null)).isTrue(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // 400 InvalidArgument (chunk offset / payload mismatch) | ||||||||||||||||||
| InvalidArgumentException invalidArgument = | ||||||||||||||||||
| new InvalidArgumentException( | ||||||||||||||||||
| "invalid argument", null, FakeStatusCode.of(StatusCode.Code.INVALID_ARGUMENT), false); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(invalidArgument, null)).isTrue(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // 412 FailedPrecondition | ||||||||||||||||||
| FailedPreconditionException failedPrecondition = | ||||||||||||||||||
| new FailedPreconditionException( | ||||||||||||||||||
| "failed precondition", | ||||||||||||||||||
| null, | ||||||||||||||||||
| FakeStatusCode.of(StatusCode.Code.FAILED_PRECONDITION), | ||||||||||||||||||
| false); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(failedPrecondition, null)).isTrue(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // 409 Aborted (conflict) | ||||||||||||||||||
| AbortedException aborted = | ||||||||||||||||||
| new AbortedException("aborted", null, FakeStatusCode.of(StatusCode.Code.ABORTED), false); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(aborted, null)).isTrue(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // 500 / Protocol Internal error (missing status headers) | ||||||||||||||||||
| InternalException internal = | ||||||||||||||||||
| new InternalException( | ||||||||||||||||||
| "internal protocol error", null, FakeStatusCode.of(StatusCode.Code.INTERNAL), false); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(internal, null)).isTrue(); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| @Test | ||||||||||||||||||
| void testCategory3FatalTerminalErrors_shouldNotRetry() { | ||||||||||||||||||
| // 404 NotFound (session expired or invalid) | ||||||||||||||||||
| NotFoundException notFound = | ||||||||||||||||||
| new NotFoundException( | ||||||||||||||||||
| "session not found", null, FakeStatusCode.of(StatusCode.Code.NOT_FOUND), false); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(notFound, null)).isFalse(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // 401 Unauthenticated | ||||||||||||||||||
| UnauthenticatedException unauthenticated = | ||||||||||||||||||
| new UnauthenticatedException( | ||||||||||||||||||
| "unauthenticated", null, FakeStatusCode.of(StatusCode.Code.UNAUTHENTICATED), false); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(unauthenticated, null)).isFalse(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // 403 PermissionDenied | ||||||||||||||||||
| PermissionDeniedException permissionDenied = | ||||||||||||||||||
| new PermissionDeniedException( | ||||||||||||||||||
| "permission denied", null, FakeStatusCode.of(StatusCode.Code.PERMISSION_DENIED), false); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(permissionDenied, null)).isFalse(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Cancellation | ||||||||||||||||||
| CancellationException cancellation = new CancellationException("cancelled"); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(cancellation, null)).isFalse(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Generic RuntimeException | ||||||||||||||||||
| RuntimeException runtime = new RuntimeException("unexpected"); | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(runtime, null)).isFalse(); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| @Test | ||||||||||||||||||
| void testRetryingContextOverride() { | ||||||||||||||||||
| FakeCallContext contextWithEmptyCodes = | ||||||||||||||||||
| FakeCallContext.createDefault().withRetryableCodes(Collections.emptySet()); | ||||||||||||||||||
|
|
||||||||||||||||||
| UnavailableException unavailable = | ||||||||||||||||||
| new UnavailableException( | ||||||||||||||||||
| "unavailable", null, FakeStatusCode.of(StatusCode.Code.UNAVAILABLE), false); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Default algorithm retries UNAVAILABLE, but context with empty codes forbids it | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(contextWithEmptyCodes, unavailable, null)).isFalse(); | ||||||||||||||||||
|
|
||||||||||||||||||
|
Comment on lines
+157
to
+159
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add an assertion to verify that
Suggested change
|
||||||||||||||||||
| FakeCallContext contextWithCustomCodes = | ||||||||||||||||||
| FakeCallContext.createDefault() | ||||||||||||||||||
| .withRetryableCodes(ImmutableSet.of(StatusCode.Code.NOT_FOUND)); | ||||||||||||||||||
|
|
||||||||||||||||||
| NotFoundException notFound = | ||||||||||||||||||
| new NotFoundException( | ||||||||||||||||||
| "not found", null, FakeStatusCode.of(StatusCode.Code.NOT_FOUND), false); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Custom context allows NOT_FOUND | ||||||||||||||||||
| assertThat(algorithm.shouldRetry(contextWithCustomCodes, notFound, null)).isTrue(); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
context.getRetryableCodes()is not null, it indicates that the caller has explicitly overridden the retryable codes for this call (for example, by setting an empty set to disable retries).In the current implementation, if
previousThrowableis not an instance ofApiException(such as anIOException), the code falls through toshouldRetry(previousThrowable, previousResponse), which returnstrueforIOException. This meansIOExceptions would still be retried even if retries were explicitly disabled or customized via the context.To ensure consistency with other retry algorithms in GAX (like
ApiExceptionRetryAlgorithm), we should returnfalsefor any non-ApiExceptionwhencontext.getRetryableCodes()is configured.