diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCommand.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCommand.java new file mode 100644 index 000000000000..b13f313aab1e --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCommand.java @@ -0,0 +1,52 @@ +/* + * 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.rpc; + +import org.jspecify.annotations.NullMarked; + +/** Enumeration of the supported resumable upload wire commands. */ +@NullMarked +enum ResumableUploadCommand { + START(false), + UPLOAD(true), + FINALIZE(true), + UPLOAD_FINALIZE(true), + QUERY(false); + + private final boolean isRecoverable; + + ResumableUploadCommand(boolean isRecoverable) { + this.isRecoverable = isRecoverable; + } + + boolean isRecoverable() { + return isRecoverable; + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadErrorClassifier.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadErrorClassifier.java new file mode 100644 index 000000000000..9f0a9fda187d --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadErrorClassifier.java @@ -0,0 +1,104 @@ +/* + * 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.rpc; + +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import java.util.Objects; +import org.jspecify.annotations.NullMarked; + +/** + * Classifies exceptions encountered during resumable upload commands to determine whether/how to + * recover. + */ +@NullMarked +final class ResumableUploadErrorClassifier { + + enum Category { + /** An errored command that can be retried directly. */ + TRANSIENT, + + /** An errored command that requires status from the server before recovery. */ + RECOVERABLE, + + /** An errored command that immediately fails the upload operation. */ + FATAL + } + + private static final ImmutableMap HTTP_STATUS_MAP = + ImmutableMap.builder() + .put(408, Category.TRANSIENT) + .put(429, Category.TRANSIENT) + .put(500, Category.TRANSIENT) + .put(502, Category.TRANSIENT) + .put(503, Category.TRANSIENT) + .put(504, Category.TRANSIENT) + .put(400, Category.RECOVERABLE) + .put(409, Category.RECOVERABLE) + .put(412, Category.RECOVERABLE) + .put(416, Category.RECOVERABLE) + .build(); + + private ResumableUploadErrorClassifier() {} + + /** + * Classifies an exception for the given upload command according to resumable upload protocol + * rules. + * + * @param t the error to classify + * @param command the upload command that produced the error + * @return the classified error category + */ + static Category classify(Throwable t, ResumableUploadCommand command) { + Objects.requireNonNull(t, "t must not be null"); + Objects.requireNonNull(command, "command must not be null"); + + if (!(t instanceof ApiException)) { + return Category.FATAL; + } + ApiException apiException = (ApiException) t; + StatusCode statusCode = apiException.getStatusCode(); + + // HttpJsonApiExceptionFactory wraps low-level network/socket IOExceptions (e.g. connection + // reset) as UNKNOWN. + if (statusCode.getCode() == StatusCode.Code.UNKNOWN) { + if (apiException.getCause() instanceof IOException) { + return Category.TRANSIENT; + } + return Category.FATAL; + } + + Category category = HTTP_STATUS_MAP.getOrDefault(statusCode.getTransportCode(), Category.FATAL); + if (category == Category.RECOVERABLE && !command.isRecoverable()) { + return Category.FATAL; + } + return category; + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithm.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithm.java new file mode 100644 index 000000000000..cdff71bfd258 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithm.java @@ -0,0 +1,69 @@ +/* + * 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.rpc; + +import static com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category.TRANSIENT; + +import com.google.api.gax.retrying.BasicResultRetryAlgorithm; +import com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category; +import java.util.Objects; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * An adapter that integrates {@link ResumableUploadErrorClassifier} into GAX retrying machinery. + * + *

Only transient errors should retry with an identical request; other recoverable errors will + * need to query the upload server to determine the appropriate next request. + * + * @param the response type of the upload attempt + */ +@NullMarked +final class ResumableUploadResultRetryAlgorithm + extends BasicResultRetryAlgorithm { + + private final ResumableUploadCommand command; + + ResumableUploadResultRetryAlgorithm(ResumableUploadCommand command) { + this.command = Objects.requireNonNull(command); + } + + @Override + public boolean shouldRetry( + @Nullable Throwable previousThrowable, @Nullable ResponseT previousResponse) { + // Successful commands should not retry. + if (previousThrowable == null) { + return false; + } + // Transient errors are retried directly with the identical request, others are not. + Category category = ResumableUploadErrorClassifier.classify(previousThrowable, command); + return category == TRANSIENT; + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadErrorClassifierTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadErrorClassifierTest.java new file mode 100644 index 000000000000..677f84160490 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadErrorClassifierTest.java @@ -0,0 +1,149 @@ +/* + * 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.rpc; + +import static com.google.api.gax.rpc.ResumableUploadCommand.FINALIZE; +import static com.google.api.gax.rpc.ResumableUploadCommand.QUERY; +import static com.google.api.gax.rpc.ResumableUploadCommand.START; +import static com.google.api.gax.rpc.ResumableUploadCommand.UPLOAD; +import static com.google.api.gax.rpc.ResumableUploadCommand.UPLOAD_FINALIZE; +import static com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category.FATAL; +import static com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category.RECOVERABLE; +import static com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category.TRANSIENT; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.api.gax.rpc.StatusCode.Code; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.util.concurrent.CancellationException; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +class ResumableUploadErrorClassifierTest { + + private static StatusCode statusCode(@Nullable Integer httpStatus, Code code) { + return new StatusCode() { + @Override + public Code getCode() { + return code; + } + + @Override + public @Nullable Object getTransportCode() { + return httpStatus; + } + }; + } + + private static ApiException createApiException(@Nullable Integer httpStatus, Code code) { + return createApiException(httpStatus, code, null); + } + + private static ApiException createApiException( + @Nullable Integer httpStatus, Code code, @Nullable Throwable cause) { + return ApiExceptionFactory.createException( + "HTTP " + httpStatus, cause, statusCode(httpStatus, code), false); + } + + @Test + void testTransientHttpErrors_areTransientAcrossCommands() { + ApiException error503 = createApiException(503, Code.UNAVAILABLE); + assertThat(ResumableUploadErrorClassifier.classify(error503, START)).isEqualTo(TRANSIENT); + assertThat(ResumableUploadErrorClassifier.classify(error503, UPLOAD)).isEqualTo(TRANSIENT); + assertThat(ResumableUploadErrorClassifier.classify(error503, QUERY)).isEqualTo(TRANSIENT); + } + + @Test + void testUnknownStatusCode_transientOnlyWhenCausedByIoException() { + ApiException wrappedIo = + createApiException(500, Code.UNKNOWN, new IOException("connection reset")); + assertThat(ResumableUploadErrorClassifier.classify(wrappedIo, UPLOAD)).isEqualTo(TRANSIENT); + assertThat(ResumableUploadErrorClassifier.classify(wrappedIo, QUERY)).isEqualTo(TRANSIENT); + + ApiException wrappedTimeout = + createApiException(500, Code.UNKNOWN, new SocketTimeoutException("read timeout")); + assertThat(ResumableUploadErrorClassifier.classify(wrappedTimeout, START)).isEqualTo(TRANSIENT); + + ApiException wrappedRuntime = + createApiException(500, Code.UNKNOWN, new IllegalStateException("local bug")); + assertThat(ResumableUploadErrorClassifier.classify(wrappedRuntime, UPLOAD)).isEqualTo(FATAL); + } + + @Test + void testRecoverableHttpErrors_dependOnCommand() { + ApiException error412 = createApiException(412, Code.FAILED_PRECONDITION); + + // Chunk transmission commands can recover by querying server status. + assertThat(ResumableUploadErrorClassifier.classify(error412, UPLOAD)).isEqualTo(RECOVERABLE); + assertThat(ResumableUploadErrorClassifier.classify(error412, FINALIZE)).isEqualTo(RECOVERABLE); + assertThat(ResumableUploadErrorClassifier.classify(error412, UPLOAD_FINALIZE)) + .isEqualTo(RECOVERABLE); + + // Non-chunk commands cannot recover via byte-offset query. + assertThat(ResumableUploadErrorClassifier.classify(error412, START)).isEqualTo(FATAL); + assertThat(ResumableUploadErrorClassifier.classify(error412, QUERY)).isEqualTo(FATAL); + } + + @Test + void testCommand_isRecoverable() { + assertThat(START.isRecoverable()).isFalse(); + assertThat(UPLOAD.isRecoverable()).isTrue(); + assertThat(FINALIZE.isRecoverable()).isTrue(); + assertThat(UPLOAD_FINALIZE.isRecoverable()).isTrue(); + assertThat(QUERY.isRecoverable()).isFalse(); + } + + @Test + void testUnmappedHttpCodesAndNonApiExceptions_areFatal() { + assertThat( + ResumableUploadErrorClassifier.classify( + createApiException(403, Code.PERMISSION_DENIED), UPLOAD)) + .isEqualTo(FATAL); + assertThat( + ResumableUploadErrorClassifier.classify( + createApiException(null, Code.FAILED_PRECONDITION), UPLOAD)) + .isEqualTo(FATAL); + assertThat( + ResumableUploadErrorClassifier.classify(new CancellationException("cancelled"), UPLOAD)) + .isEqualTo(FATAL); + assertThat( + ResumableUploadErrorClassifier.classify(new IllegalStateException("bad state"), UPLOAD)) + .isEqualTo(FATAL); + + assertThrows( + NullPointerException.class, () -> ResumableUploadErrorClassifier.classify(null, UPLOAD)); + assertThrows( + NullPointerException.class, + () -> + ResumableUploadErrorClassifier.classify( + createApiException(null, Code.FAILED_PRECONDITION), null)); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithmTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithmTest.java new file mode 100644 index 000000000000..399979a7ec21 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithmTest.java @@ -0,0 +1,98 @@ +/* + * 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.rpc; + +import static com.google.api.gax.rpc.ResumableUploadCommand.UPLOAD; +import static com.google.common.truth.Truth.assertThat; + +import com.google.api.gax.rpc.StatusCode.Code; +import java.io.IOException; +import java.util.concurrent.CancellationException; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +class ResumableUploadResultRetryAlgorithmTest { + + private final ResumableUploadResultRetryAlgorithm algorithm = + new ResumableUploadResultRetryAlgorithm<>(UPLOAD); + + private static ApiException createApiException(@Nullable Integer httpStatus, Code code) { + return createApiException(httpStatus, code, null); + } + + private static ApiException createApiException( + @Nullable Integer httpStatus, Code code, @Nullable Throwable cause) { + return ApiExceptionFactory.createException( + "HTTP " + httpStatus, + cause, + new StatusCode() { + @Override + public Code getCode() { + return code; + } + + @Override + public @Nullable Object getTransportCode() { + return httpStatus; + } + }, + false); + } + + @Test + void testShouldRetry_nullThrowableReturnsFalse() { + assertThat(algorithm.shouldRetry(null, "success")).isFalse(); + } + + @Test + void testShouldRetry_transientErrorReturnsTrue() { + ApiException wrappedIo = + createApiException(500, Code.UNKNOWN, new IOException("connection reset")); + assertThat(algorithm.shouldRetry(wrappedIo, null)).isTrue(); + + ApiException transient503 = createApiException(503, Code.UNAVAILABLE); + assertThat(algorithm.shouldRetry(transient503, null)).isTrue(); + } + + @Test + void testShouldRetry_recoverableErrorReturnsFalse() { + ApiException recoverable412 = createApiException(412, Code.FAILED_PRECONDITION); + assertThat(algorithm.shouldRetry(recoverable412, null)).isFalse(); + } + + @Test + void testShouldRetry_fatalErrorReturnsFalse() { + assertThat(algorithm.shouldRetry(createApiException(403, Code.PERMISSION_DENIED), null)) + .isFalse(); + assertThat(algorithm.shouldRetry(createApiException(null, Code.FAILED_PRECONDITION), null)) + .isFalse(); + assertThat(algorithm.shouldRetry(new CancellationException("cancelled"), null)).isFalse(); + } +}