From 54053ca2807f21d97a3ec0a885c91b8e7a6632cb Mon Sep 17 00:00:00 2001 From: whowes Date: Mon, 31 Aug 2026 17:36:20 +0000 Subject: [PATCH] feat(gax): add ResumableUploadResultRetryAlgorithm --- .../ResumableUploadResultRetryAlgorithm.java | 137 ++++++++++++++ ...sumableUploadResultRetryAlgorithmTest.java | 171 ++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithm.java create mode 100644 sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithmTest.java diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithm.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithm.java new file mode 100644 index 000000000000..70b8c73a71d8 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithm.java @@ -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. + * + *

Differentiates between: + * + *

+ */ +@BetaApi +@InternalApi +@NullMarked +public class ResumableUploadResultRetryAlgorithm + extends BasicResultRetryAlgorithm { + + private static final Set 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 retryableCodes; + + public static ResumableUploadResultRetryAlgorithm create() { + return new ResumableUploadResultRetryAlgorithm<>(DEFAULT_RETRYABLE_CODES); + } + + public static ResumableUploadResultRetryAlgorithm create( + Set retryableCodes) { + return new ResumableUploadResultRetryAlgorithm<>(retryableCodes); + } + + public ResumableUploadResultRetryAlgorithm() { + this(DEFAULT_RETRYABLE_CODES); + } + + public ResumableUploadResultRetryAlgorithm(Set retryableCodes) { + this.retryableCodes = ImmutableSet.copyOf(retryableCodes); + } + + public Set 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) { + return true; + } + return false; + } + + @Override + public boolean shouldRetry( + RetryingContext context, + @Nullable Throwable previousThrowable, + @Nullable ResponseT previousResponse) { + if (context.getRetryableCodes() != null) { + if (previousThrowable instanceof ApiException) { + return context + .getRetryableCodes() + .contains(((ApiException) previousThrowable).getStatusCode().getCode()); + } + } + return shouldRetry(previousThrowable, previousResponse); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithmTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithmTest.java new file mode 100644 index 000000000000..d4eae7f9c1de --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithmTest.java @@ -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 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(); + + 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(); + } +}