Skip to content
Draft
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
@@ -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

View check run for this annotation

SonarQubeCloud / [gapic-generator-java-root] SonarCloud Code Analysis

Rename this generic name to match the regular expression '^[A-Z][0-9]?$'.

See more on https://sonarcloud.io/project/issues?id=googleapis_google-cloud-java_showcase&issues=AaBbnqvdDeKN89tEP6Dx&open=AaBbnqvdDeKN89tEP6Dx&pullRequest=14225
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

View check run for this annotation

SonarQubeCloud / [gapic-generator-java-root] SonarCloud Code Analysis

Rename this generic name to match the regular expression '^[A-Z][0-9]?$'.

See more on https://sonarcloud.io/project/issues?id=googleapis_google-cloud-java_showcase&issues=AaBbnqvdDeKN89tEP6Dy&open=AaBbnqvdDeKN89tEP6Dy&pullRequest=14225
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

View check run for this annotation

SonarQubeCloud / [gapic-generator-java-root] SonarCloud Code Analysis

Rename this generic name to match the regular expression '^[A-Z][0-9]?$'.

See more on https://sonarcloud.io/project/issues?id=googleapis_google-cloud-java_showcase&issues=AaBbnqvdDeKN89tEP6Dz&open=AaBbnqvdDeKN89tEP6Dz&pullRequest=14225
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

View check run for this annotation

SonarQubeCloud / [gapic-generator-java-root] SonarCloud Code Analysis

Replace this if-then-else statement by a single return statement.

See more on https://sonarcloud.io/project/issues?id=googleapis_google-cloud-java_showcase&issues=AaBbnqvdDeKN89tEP6D0&open=AaBbnqvdDeKN89tEP6D0&pullRequest=14225
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

View check run for this annotation

SonarQubeCloud / [gapic-generator-java-root] SonarCloud Code Analysis

Merge this if statement with the enclosing one.

See more on https://sonarcloud.io/project/issues?id=googleapis_google-cloud-java_showcase&issues=AaBbnqvdDeKN89tEP6Dw&open=AaBbnqvdDeKN89tEP6Dw&pullRequest=14225
return context
.getRetryableCodes()
.contains(((ApiException) previousThrowable).getStatusCode().getCode());
}
}
Comment on lines +128 to +134

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.

high

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 previousThrowable is not an instance of ApiException (such as an IOException), the code falls through to shouldRetry(previousThrowable, previousResponse), which returns true for IOException. This means IOExceptions 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 return false for any non-ApiException when context.getRetryableCodes() is configured.

Suggested change
if (context.getRetryableCodes() != null) {
if (previousThrowable instanceof ApiException) {
return context
.getRetryableCodes()
.contains(((ApiException) previousThrowable).getStatusCode().getCode());
}
}
if (context.getRetryableCodes() != null) {
if (previousThrowable instanceof ApiException) {
return context
.getRetryableCodes()
.contains(((ApiException) previousThrowable).getStatusCode().getCode());
}
return false;
}

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

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

Add an assertion to verify that IOException is not retried when the context overrides the retryable codes (e.g., with an empty set).

Suggested change
// Default algorithm retries UNAVAILABLE, but context with empty codes forbids it
assertThat(algorithm.shouldRetry(contextWithEmptyCodes, unavailable, null)).isFalse();
// Default algorithm retries UNAVAILABLE, but context with empty codes forbids it
assertThat(algorithm.shouldRetry(contextWithEmptyCodes, unavailable, null)).isFalse();
// IOException should also not be retried when retryable codes are overridden by the context
IOException ioException = new IOException("connection reset");
assertThat(algorithm.shouldRetry(contextWithEmptyCodes, ioException, null)).isFalse();

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();
}
}
Loading